.');
109 | }
110 | return this.element;
111 | }
112 | dispatchEvent(name, payload) {
113 | this.dispatch(name, { detail: payload, prefix: 'autocomplete' });
114 | }
115 | get preload() {
116 | if (!this.hasPreloadValue) {
117 | return 'focus';
118 | }
119 | if (this.preloadValue === 'false') {
120 | return false;
121 | }
122 | if (this.preloadValue === 'true') {
123 | return true;
124 | }
125 | return this.preloadValue;
126 | }
127 | resetTomSelect() {
128 | if (this.tomSelect) {
129 | this.dispatchEvent('before-reset', { tomSelect: this.tomSelect });
130 | this.stopMutationObserver();
131 | const currentHtml = this.element.innerHTML;
132 | const currentValue = this.tomSelect.getValue();
133 | this.tomSelect.destroy();
134 | this.element.innerHTML = currentHtml;
135 | this.initializeTomSelect();
136 | this.tomSelect.setValue(currentValue);
137 | }
138 | }
139 | changeTomSelectDisabledState(isDisabled) {
140 | this.stopMutationObserver();
141 | if (isDisabled) {
142 | this.tomSelect.disable();
143 | }
144 | else {
145 | this.tomSelect.enable();
146 | }
147 | this.startMutationObserver();
148 | }
149 | startMutationObserver() {
150 | if (!this.isObserving && this.mutationObserver) {
151 | this.mutationObserver.observe(this.element, {
152 | childList: true,
153 | subtree: true,
154 | attributes: true,
155 | characterData: true,
156 | attributeOldValue: true,
157 | });
158 | this.isObserving = true;
159 | }
160 | }
161 | stopMutationObserver() {
162 | if (this.isObserving && this.mutationObserver) {
163 | this.mutationObserver.disconnect();
164 | this.isObserving = false;
165 | }
166 | }
167 | onMutations(mutations) {
168 | let changeDisabledState = false;
169 | let requireReset = false;
170 | mutations.forEach((mutation) => {
171 | switch (mutation.type) {
172 | case 'attributes':
173 | if (mutation.target === this.element && mutation.attributeName === 'disabled') {
174 | changeDisabledState = true;
175 | break;
176 | }
177 | if (mutation.target === this.element && mutation.attributeName === 'multiple') {
178 | const isNowMultiple = this.element.hasAttribute('multiple');
179 | const wasMultiple = mutation.oldValue === 'multiple';
180 | if (isNowMultiple !== wasMultiple) {
181 | requireReset = true;
182 | }
183 | break;
184 | }
185 | break;
186 | }
187 | });
188 | const newOptions = this.selectElement ? this.createOptionsDataStructure(this.selectElement) : [];
189 | const areOptionsEquivalent = this.areOptionsEquivalent(newOptions);
190 | if (!areOptionsEquivalent || requireReset) {
191 | this.originalOptions = newOptions;
192 | this.resetTomSelect();
193 | }
194 | if (changeDisabledState) {
195 | this.changeTomSelectDisabledState(this.formElement.disabled);
196 | }
197 | }
198 | createOptionsDataStructure(selectElement) {
199 | return Array.from(selectElement.options).map((option) => {
200 | return {
201 | value: option.value,
202 | text: option.text,
203 | };
204 | });
205 | }
206 | areOptionsEquivalent(newOptions) {
207 | const filteredOriginalOptions = this.originalOptions.filter((option) => option.value !== '');
208 | const filteredNewOptions = newOptions.filter((option) => option.value !== '');
209 | const originalPlaceholderOption = this.originalOptions.find((option) => option.value === '');
210 | const newPlaceholderOption = newOptions.find((option) => option.value === '');
211 | if (originalPlaceholderOption &&
212 | newPlaceholderOption &&
213 | originalPlaceholderOption.text !== newPlaceholderOption.text) {
214 | return false;
215 | }
216 | if (filteredOriginalOptions.length !== filteredNewOptions.length) {
217 | return false;
218 | }
219 | const normalizeOption = (option) => `${option.value}-${option.text}`;
220 | const originalOptionsSet = new Set(filteredOriginalOptions.map(normalizeOption));
221 | const newOptionsSet = new Set(filteredNewOptions.map(normalizeOption));
222 | return (originalOptionsSet.size === newOptionsSet.size &&
223 | [...originalOptionsSet].every((option) => newOptionsSet.has(option)));
224 | }
225 | }
226 | _default_1_instances = new WeakSet(), _default_1_getCommonConfig = function _default_1_getCommonConfig() {
227 | const plugins = {};
228 | const isMultiple = !this.selectElement || this.selectElement.multiple;
229 | if (!this.formElement.disabled && !isMultiple) {
230 | plugins.clear_button = { title: '' };
231 | }
232 | if (isMultiple) {
233 | plugins.remove_button = { title: '' };
234 | }
235 | if (this.urlValue) {
236 | plugins.virtual_scroll = {};
237 | }
238 | const render = {
239 | no_results: () => {
240 | return `${this.noResultsFoundTextValue}
`;
241 | },
242 | option_create: (data, escapeData) => {
243 | return `${this.createOptionTextValue.replace('%placeholder%', `${escapeData(data.input)} `)}
`;
244 | },
245 | };
246 | const config = {
247 | render,
248 | plugins,
249 | onItemAdd: () => {
250 | this.tomSelect.setTextboxValue('');
251 | },
252 | closeAfterSelect: true,
253 | onOptionAdd: (value, data) => {
254 | let parentElement = this.tomSelect.input;
255 | let optgroupData = null;
256 | const optgroup = data[this.tomSelect.settings.optgroupField];
257 | if (optgroup && this.tomSelect.optgroups) {
258 | optgroupData = this.tomSelect.optgroups[optgroup];
259 | if (optgroupData) {
260 | const optgroupElement = parentElement.querySelector(`optgroup[label="${optgroupData.label}"]`);
261 | if (optgroupElement) {
262 | parentElement = optgroupElement;
263 | }
264 | }
265 | }
266 | const optionElement = document.createElement('option');
267 | optionElement.value = value;
268 | optionElement.text = data[this.tomSelect.settings.labelField];
269 | const optionOrder = data.$order;
270 | let orderedOption = null;
271 | for (const [, tomSelectOption] of Object.entries(this.tomSelect.options)) {
272 | if (tomSelectOption.$order === optionOrder) {
273 | orderedOption = parentElement.querySelector(`:scope > option[value="${CSS.escape(tomSelectOption[this.tomSelect.settings.valueField])}"]`);
274 | break;
275 | }
276 | }
277 | if (orderedOption) {
278 | orderedOption.insertAdjacentElement('afterend', optionElement);
279 | }
280 | else if (optionOrder >= 0) {
281 | parentElement.append(optionElement);
282 | }
283 | else {
284 | parentElement.prepend(optionElement);
285 | }
286 | },
287 | };
288 | if (!this.selectElement && !this.urlValue) {
289 | config.shouldLoad = () => false;
290 | }
291 | return __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_mergeObjects).call(this, config, this.tomSelectOptionsValue);
292 | }, _default_1_createAutocomplete = function _default_1_createAutocomplete() {
293 | const config = __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_mergeObjects).call(this, __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_getCommonConfig).call(this), {
294 | maxOptions: this.getMaxOptions(),
295 | });
296 | return __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_createTomSelect).call(this, config);
297 | }, _default_1_createAutocompleteWithHtmlContents = function _default_1_createAutocompleteWithHtmlContents() {
298 | const commonConfig = __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_getCommonConfig).call(this);
299 | const labelField = commonConfig.labelField ?? 'text';
300 | const config = __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_mergeObjects).call(this, commonConfig, {
301 | maxOptions: this.getMaxOptions(),
302 | score: (search) => {
303 | const scoringFunction = this.tomSelect.getScoreFunction(search);
304 | return (item) => {
305 | return scoringFunction({ ...item, text: __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_stripTags).call(this, item[labelField]) });
306 | };
307 | },
308 | render: {
309 | item: (item) => `${item[labelField]}
`,
310 | option: (item) => `${item[labelField]}
`,
311 | },
312 | });
313 | return __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_createTomSelect).call(this, config);
314 | }, _default_1_createAutocompleteWithRemoteData = function _default_1_createAutocompleteWithRemoteData(autocompleteEndpointUrl, minCharacterLength) {
315 | const commonConfig = __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_getCommonConfig).call(this);
316 | const labelField = commonConfig.labelField ?? 'text';
317 | const config = __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_mergeObjects).call(this, commonConfig, {
318 | firstUrl: (query) => {
319 | const separator = autocompleteEndpointUrl.includes('?') ? '&' : '?';
320 | return `${autocompleteEndpointUrl}${separator}query=${encodeURIComponent(query)}`;
321 | },
322 | load: function (query, callback) {
323 | const url = this.getUrl(query);
324 | fetch(url)
325 | .then((response) => response.json())
326 | .then((json) => {
327 | this.setNextUrl(query, json.next_page);
328 | callback(json.results.options || json.results, json.results.optgroups || []);
329 | })
330 | .catch(() => callback([], []));
331 | },
332 | shouldLoad: (query) => {
333 | if (null !== minCharacterLength) {
334 | return query.length >= minCharacterLength;
335 | }
336 | if (this.hasLoadedChoicesPreviously) {
337 | return true;
338 | }
339 | if (query.length > 0) {
340 | this.hasLoadedChoicesPreviously = true;
341 | }
342 | return query.length >= 3;
343 | },
344 | optgroupField: 'group_by',
345 | score: (search) => (item) => 1,
346 | render: {
347 | option: (item) => `${item[labelField]}
`,
348 | item: (item) => `${item[labelField]}
`,
349 | loading_more: () => {
350 | return `${this.loadingMoreTextValue}
`;
351 | },
352 | no_more_results: () => {
353 | return `${this.noMoreResultsTextValue}
`;
354 | },
355 | no_results: () => {
356 | return `${this.noResultsFoundTextValue}
`;
357 | },
358 | option_create: (data, escapeData) => {
359 | return `${this.createOptionTextValue.replace('%placeholder%', `${escapeData(data.input)} `)}
`;
360 | },
361 | },
362 | preload: this.preload,
363 | });
364 | return __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_createTomSelect).call(this, config);
365 | }, _default_1_stripTags = function _default_1_stripTags(string) {
366 | return string.replace(/(<([^>]+)>)/gi, '');
367 | }, _default_1_mergeObjects = function _default_1_mergeObjects(object1, object2) {
368 | return { ...object1, ...object2 };
369 | }, _default_1_createTomSelect = function _default_1_createTomSelect(options) {
370 | const preConnectPayload = { options };
371 | this.dispatchEvent('pre-connect', preConnectPayload);
372 | const tomSelect = new TomSelect(this.formElement, options);
373 | const connectPayload = { tomSelect, options };
374 | this.dispatchEvent('connect', connectPayload);
375 | return tomSelect;
376 | };
377 | default_1.values = {
378 | url: String,
379 | optionsAsHtml: Boolean,
380 | loadingMoreText: String,
381 | noResultsFoundText: String,
382 | noMoreResultsText: String,
383 | createOptionText: String,
384 | minCharacters: Number,
385 | tomSelectOptions: Object,
386 | preload: String,
387 | };
388 |
389 | export { default_1 as default };
390 |
--------------------------------------------------------------------------------
/assets/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@symfony/ux-autocomplete",
3 | "description": "JavaScript Autocomplete functionality for Symfony",
4 | "license": "MIT",
5 | "version": "2.26.0",
6 | "keywords": [
7 | "symfony-ux"
8 | ],
9 | "homepage": "https://ux.symfony.com/autocomplete",
10 | "repository": "https://github.com/symfony/ux-autocomplete",
11 | "type": "module",
12 | "files": [
13 | "dist"
14 | ],
15 | "main": "dist/controller.js",
16 | "types": "dist/controller.d.ts",
17 | "scripts": {
18 | "build": "node ../../../bin/build_package.js .",
19 | "watch": "node ../../../bin/build_package.js . --watch",
20 | "test": "../../../bin/test_package.sh .",
21 | "check": "biome check",
22 | "ci": "biome ci"
23 | },
24 | "symfony": {
25 | "controllers": {
26 | "autocomplete": {
27 | "main": "dist/controller.js",
28 | "webpackMode": "eager",
29 | "fetch": "eager",
30 | "enabled": true,
31 | "autoimport": {
32 | "tom-select/dist/css/tom-select.default.css": true,
33 | "tom-select/dist/css/tom-select.bootstrap4.css": false,
34 | "tom-select/dist/css/tom-select.bootstrap5.css": false
35 | }
36 | }
37 | },
38 | "importmap": {
39 | "@hotwired/stimulus": "^3.0.0",
40 | "tom-select": "^2.2.2"
41 | }
42 | },
43 | "peerDependencies": {
44 | "@hotwired/stimulus": "^3.0.0",
45 | "tom-select": "^2.2.2"
46 | },
47 | "devDependencies": {
48 | "@hotwired/stimulus": "^3.0.0",
49 | "tom-select": "^2.2.2",
50 | "vitest-fetch-mock": "^0.2.2"
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "symfony/ux-autocomplete",
3 | "type": "symfony-bundle",
4 | "description": "JavaScript Autocomplete functionality for Symfony",
5 | "keywords": [
6 | "symfony-ux"
7 | ],
8 | "homepage": "https://symfony.com",
9 | "license": "MIT",
10 | "authors": [
11 | {
12 | "name": "Symfony Community",
13 | "homepage": "https://symfony.com/contributors"
14 | }
15 | ],
16 | "autoload": {
17 | "psr-4": {
18 | "Symfony\\UX\\Autocomplete\\": "src/"
19 | }
20 | },
21 | "autoload-dev": {
22 | "psr-4": {
23 | "Symfony\\UX\\Autocomplete\\Tests\\": "tests/"
24 | }
25 | },
26 | "require": {
27 | "php": ">=8.1",
28 | "symfony/dependency-injection": "^6.3|^7.0",
29 | "symfony/deprecation-contracts": "^2.5|^3",
30 | "symfony/http-foundation": "^6.3|^7.0",
31 | "symfony/http-kernel": "^6.3|^7.0",
32 | "symfony/property-access": "^6.3|^7.0"
33 | },
34 | "require-dev": {
35 | "doctrine/collections": "^1.6.8|^2.0",
36 | "doctrine/doctrine-bundle": "^2.4.3",
37 | "doctrine/orm": "^2.9.4|^3.0",
38 | "fakerphp/faker": "^1.22",
39 | "mtdowling/jmespath.php": "^2.6",
40 | "symfony/form": "^6.3|^7.0",
41 | "symfony/options-resolver": "^6.3|^7.0",
42 | "symfony/framework-bundle": "^6.3|^7.0",
43 | "symfony/maker-bundle": "^1.40",
44 | "symfony/phpunit-bridge": "^6.3|^7.0",
45 | "symfony/process": "^6.3|^7.0",
46 | "symfony/security-bundle": "^6.3|^7.0",
47 | "symfony/twig-bundle": "^6.3|^7.0",
48 | "symfony/uid": "^6.3|^7.0",
49 | "twig/twig": "^2.14.7|^3.0.4",
50 | "zenstruck/browser": "^1.1",
51 | "zenstruck/foundry": "1.37.*"
52 | },
53 | "conflict": {
54 | "doctrine/orm": "2.9.0 || 2.9.1"
55 | },
56 | "config": {
57 | "sort-packages": true
58 | },
59 | "extra": {
60 | "thanks": {
61 | "name": "symfony/ux",
62 | "url": "https://github.com/symfony/ux"
63 | }
64 | },
65 | "minimum-stability": "dev"
66 | }
67 |
--------------------------------------------------------------------------------
/config/routes.php:
--------------------------------------------------------------------------------
1 |
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 | use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator;
13 |
14 | return function (RoutingConfigurator $routes) {
15 | $routes->add('ux_entity_autocomplete', '/{alias}')
16 | ->controller('ux.autocomplete.entity_autocomplete_controller')
17 | ;
18 | };
19 |
--------------------------------------------------------------------------------
/src/AutocompleteBundle.php:
--------------------------------------------------------------------------------
1 |
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 | use Symfony\Component\DependencyInjection\ContainerBuilder;
15 | use Symfony\Component\HttpKernel\Bundle\Bundle;
16 | use Symfony\UX\Autocomplete\DependencyInjection\AutocompleteFormTypePass;
17 |
18 | /**
19 | * @author Ryan Weaver
20 | */
21 | final class AutocompleteBundle extends Bundle
22 | {
23 | public function build(ContainerBuilder $container): void
24 | {
25 | $container->addCompilerPass(new AutocompleteFormTypePass());
26 | }
27 |
28 | public function getPath(): string
29 | {
30 | return \dirname(__DIR__);
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/src/AutocompleteResults.php:
--------------------------------------------------------------------------------
1 |
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 | final class AutocompleteResults
15 | {
16 | /**
17 | * @param list $results
18 | */
19 | public function __construct(
20 | public array $results,
21 | public bool $hasNextPage,
22 | public array $optgroups = [],
23 | ) {
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/src/AutocompleteResultsExecutor.php:
--------------------------------------------------------------------------------
1 |
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 | use Doctrine\ORM\Tools\Pagination\Paginator;
15 | use Symfony\Bundle\SecurityBundle\Security;
16 | use Symfony\Component\PropertyAccess\Exception\UnexpectedTypeException;
17 | use Symfony\Component\PropertyAccess\PropertyAccessor;
18 | use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
19 | use Symfony\Component\PropertyAccess\PropertyPath;
20 | use Symfony\Component\PropertyAccess\PropertyPathInterface;
21 | use Symfony\Component\Security\Core\Exception\AccessDeniedException;
22 | use Symfony\UX\Autocomplete\Doctrine\DoctrineRegistryWrapper;
23 |
24 | /**
25 | * @author Ryan Weaver
26 | */
27 | final class AutocompleteResultsExecutor
28 | {
29 | private PropertyAccessorInterface $propertyAccessor;
30 | private ?Security $security;
31 |
32 | public function __construct(
33 | private DoctrineRegistryWrapper $managerRegistry,
34 | $propertyAccessor,
35 | /* Security $security = null */
36 | ) {
37 | if ($propertyAccessor instanceof Security) {
38 | trigger_deprecation('symfony/ux-autocomplete', '2.8.0', 'Passing a "%s" instance as the second argument of "%s()" is deprecated, pass a "%s" instance instead.', Security::class, __METHOD__, PropertyAccessorInterface::class);
39 | $this->security = $propertyAccessor;
40 | $this->propertyAccessor = new PropertyAccessor();
41 | } else {
42 | $this->propertyAccessor = $propertyAccessor;
43 | $this->security = \func_num_args() >= 3 ? func_get_arg(2) : null;
44 | }
45 | }
46 |
47 | public function fetchResults(EntityAutocompleterInterface $autocompleter, string $query, int $page): AutocompleteResults
48 | {
49 | if ($this->security && !$autocompleter->isGranted($this->security)) {
50 | throw new AccessDeniedException('Access denied from autocompleter class.');
51 | }
52 |
53 | $queryBuilder = $autocompleter->createFilteredQueryBuilder(
54 | $this->managerRegistry->getRepository($autocompleter->getEntityClass()),
55 | $query
56 | );
57 |
58 | // if no max is set, set one
59 | if (!$queryBuilder->getMaxResults()) {
60 | $queryBuilder->setMaxResults(10);
61 | }
62 |
63 | $page = max(1, $page);
64 |
65 | $queryBuilder->setFirstResult(($page - 1) * $queryBuilder->getMaxResults());
66 |
67 | $paginator = new Paginator($queryBuilder);
68 |
69 | $nbPages = (int) ceil($paginator->count() / $queryBuilder->getMaxResults());
70 | $hasNextPage = $page < $nbPages;
71 |
72 | $results = [];
73 |
74 | if (!method_exists($autocompleter, 'getGroupBy') || null === $groupBy = $autocompleter->getGroupBy()) {
75 | foreach ($paginator as $entity) {
76 | $results[] = $this->formatResult($autocompleter, $entity);
77 | }
78 |
79 | return new AutocompleteResults($results, $hasNextPage);
80 | }
81 |
82 | if (\is_string($groupBy)) {
83 | $groupBy = new PropertyPath($groupBy);
84 | }
85 |
86 | if ($groupBy instanceof PropertyPathInterface) {
87 | $accessor = $this->propertyAccessor;
88 | $groupBy = function ($choice) use ($accessor, $groupBy) {
89 | try {
90 | return $accessor->getValue($choice, $groupBy);
91 | } catch (UnexpectedTypeException) {
92 | return null;
93 | }
94 | };
95 | }
96 |
97 | if (!\is_callable($groupBy)) {
98 | throw new \InvalidArgumentException(\sprintf('Option "group_by" must be callable, "%s" given.', get_debug_type($groupBy)));
99 | }
100 |
101 | $optgroupLabels = [];
102 |
103 | foreach ($paginator as $entity) {
104 | $result = $this->formatResult($autocompleter, $entity);
105 |
106 | $groupLabels = $groupBy($entity, $result['value'], $result['text']);
107 |
108 | if (null !== $groupLabels) {
109 | $groupLabels = \is_array($groupLabels) ? array_map('strval', $groupLabels) : [(string) $groupLabels];
110 | $result['group_by'] = $groupLabels;
111 | $optgroupLabels = array_merge($optgroupLabels, $groupLabels);
112 | }
113 |
114 | $results[] = $result;
115 | }
116 |
117 | $optgroups = array_map(fn (string $label) => ['value' => $label, 'label' => $label], array_unique($optgroupLabels));
118 |
119 | return new AutocompleteResults($results, $hasNextPage, $optgroups);
120 | }
121 |
122 | /**
123 | * @return array
124 | */
125 | private function formatResult(EntityAutocompleterInterface $autocompleter, object $entity): array
126 | {
127 | $attributes = [];
128 | if (method_exists($autocompleter, 'getAttributes')) {
129 | $attributes = $autocompleter->getAttributes($entity);
130 | }
131 |
132 | return [
133 | ...$attributes,
134 | 'value' => $autocompleter->getValue($entity),
135 | 'text' => $autocompleter->getLabel($entity),
136 | ];
137 | }
138 | }
139 |
--------------------------------------------------------------------------------
/src/AutocompleterRegistry.php:
--------------------------------------------------------------------------------
1 |
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 | use Symfony\Component\DependencyInjection\ServiceLocator;
15 |
16 | /**
17 | * @author Ryan Weaver
18 | */
19 | final class AutocompleterRegistry
20 | {
21 | public function __construct(
22 | private ServiceLocator $autocompletersLocator,
23 | ) {
24 | }
25 |
26 | public function getAutocompleter(string $alias): ?EntityAutocompleterInterface
27 | {
28 | return $this->autocompletersLocator->has($alias) ? $this->autocompletersLocator->get($alias) : null;
29 | }
30 |
31 | /**
32 | * @return list
33 | */
34 | public function getAutocompleterNames(): array
35 | {
36 | return array_keys($this->autocompletersLocator->getProvidedServices());
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/src/Checksum/ChecksumCalculator.php:
--------------------------------------------------------------------------------
1 |
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\Checksum;
13 |
14 | /** @internal */
15 | class ChecksumCalculator
16 | {
17 | public function __construct(private readonly string $secret)
18 | {
19 | }
20 |
21 | public function calculateForArray(array $data): string
22 | {
23 | $this->sortKeysRecursively($data);
24 |
25 | return base64_encode(hash_hmac('sha256', json_encode($data), $this->secret, true));
26 | }
27 |
28 | private function sortKeysRecursively(array &$data): void
29 | {
30 | foreach ($data as &$value) {
31 | if (\is_array($value)) {
32 | $this->sortKeysRecursively($value);
33 | }
34 | }
35 | ksort($data);
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/src/Controller/EntityAutocompleteController.php:
--------------------------------------------------------------------------------
1 |
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\Controller;
13 |
14 | use Symfony\Component\HttpFoundation\JsonResponse;
15 | use Symfony\Component\HttpFoundation\Request;
16 | use Symfony\Component\HttpFoundation\Response;
17 | use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
18 | use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
19 | use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
20 | use Symfony\UX\Autocomplete\AutocompleteResultsExecutor;
21 | 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;
25 |
26 | /**
27 | * @author Ryan Weaver
28 | */
29 | final class EntityAutocompleteController
30 | {
31 | public const EXTRA_OPTIONS = 'extra_options';
32 |
33 | public function __construct(
34 | private AutocompleterRegistry $autocompleteFieldRegistry,
35 | private AutocompleteResultsExecutor $autocompleteResultsExecutor,
36 | private UrlGeneratorInterface $urlGenerator,
37 | private ChecksumCalculator $checksumCalculator,
38 | ) {
39 | }
40 |
41 | public function __invoke(string $alias, Request $request): Response
42 | {
43 | $autocompleter = $this->autocompleteFieldRegistry->getAutocompleter($alias);
44 | if (!$autocompleter) {
45 | throw new NotFoundHttpException(\sprintf('No autocompleter found for "%s". Available autocompleters are: (%s)', $alias, implode(', ', $this->autocompleteFieldRegistry->getAutocompleterNames())));
46 | }
47 |
48 | if ($autocompleter instanceof OptionsAwareEntityAutocompleterInterface) {
49 | $extraOptions = $this->getExtraOptions($request);
50 | $autocompleter->setOptions([self::EXTRA_OPTIONS => $extraOptions]);
51 | }
52 |
53 | $page = $request->query->getInt('page', 1);
54 | $nextPage = null;
55 |
56 | $data = $this->autocompleteResultsExecutor->fetchResults($autocompleter, $request->query->get('query', ''), $page);
57 |
58 | if ($data->hasNextPage) {
59 | $parameters = array_merge($request->attributes->all('_route_params'), $request->query->all(), ['page' => $page + 1]);
60 |
61 | $nextPage = $this->urlGenerator->generate($request->attributes->get('_route'), $parameters);
62 | }
63 |
64 | return new JsonResponse([
65 | 'results' => ($data->optgroups) ? ['options' => $data->results, 'optgroups' => $data->optgroups] : $data->results,
66 | 'next_page' => $nextPage,
67 | ]);
68 | }
69 |
70 | /**
71 | * @return array
72 | */
73 | private function getExtraOptions(Request $request): array
74 | {
75 | if (!$request->query->has(self::EXTRA_OPTIONS)) {
76 | return [];
77 | }
78 |
79 | try {
80 | $extraOptions = $this->getDecodedExtraOptions($request->query->getString(self::EXTRA_OPTIONS));
81 | } catch (\JsonException $e) {
82 | throw new BadRequestHttpException('The extra options cannot be parsed.', $e);
83 | }
84 |
85 | if (!\array_key_exists(AutocompleteChoiceTypeExtension::CHECKSUM_KEY, $extraOptions)) {
86 | throw new BadRequestHttpException('The extra options are missing the checksum.');
87 | }
88 |
89 | $this->validateChecksum($extraOptions[AutocompleteChoiceTypeExtension::CHECKSUM_KEY], $extraOptions);
90 |
91 | return $extraOptions;
92 | }
93 |
94 | /**
95 | * @return array
96 | */
97 | private function getDecodedExtraOptions(string $extraOptions): array
98 | {
99 | return json_decode(base64_decode($extraOptions), true, flags: \JSON_THROW_ON_ERROR);
100 | }
101 |
102 | /**
103 | * @param array $extraOptions
104 | */
105 | private function validateChecksum(string $checksum, array $extraOptions): void
106 | {
107 | $extraOptionsWithoutChecksum = array_filter(
108 | $extraOptions,
109 | fn (string $key) => AutocompleteChoiceTypeExtension::CHECKSUM_KEY !== $key,
110 | \ARRAY_FILTER_USE_KEY,
111 | );
112 |
113 | if ($checksum !== $this->checksumCalculator->calculateForArray($extraOptionsWithoutChecksum)) {
114 | throw new BadRequestHttpException('The extra options have been tampered with.');
115 | }
116 | }
117 | }
118 |
--------------------------------------------------------------------------------
/src/DependencyInjection/AutocompleteExtension.php:
--------------------------------------------------------------------------------
1 |
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\DependencyInjection;
13 |
14 | use Symfony\Component\AssetMapper\AssetMapperInterface;
15 | use Symfony\Component\DependencyInjection\ContainerBuilder;
16 | use Symfony\Component\DependencyInjection\ContainerInterface;
17 | use Symfony\Component\DependencyInjection\Definition;
18 | use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface;
19 | use Symfony\Component\DependencyInjection\Reference;
20 | use Symfony\Component\Form\Form;
21 | use Symfony\Component\HttpKernel\DependencyInjection\Extension;
22 | use Symfony\UX\Autocomplete\AutocompleteResultsExecutor;
23 | use Symfony\UX\Autocomplete\AutocompleterRegistry;
24 | use Symfony\UX\Autocomplete\Checksum\ChecksumCalculator;
25 | use Symfony\UX\Autocomplete\Controller\EntityAutocompleteController;
26 | use Symfony\UX\Autocomplete\Doctrine\DoctrineRegistryWrapper;
27 | use Symfony\UX\Autocomplete\Doctrine\EntityMetadataFactory;
28 | use Symfony\UX\Autocomplete\Doctrine\EntitySearchUtil;
29 | use Symfony\UX\Autocomplete\Form\AsEntityAutocompleteField;
30 | use Symfony\UX\Autocomplete\Form\AutocompleteChoiceTypeExtension;
31 | use Symfony\UX\Autocomplete\Form\BaseEntityAutocompleteType;
32 | use Symfony\UX\Autocomplete\Form\ParentEntityAutocompleteType;
33 | use Symfony\UX\Autocomplete\Form\WrappedEntityTypeAutocompleter;
34 | use Symfony\UX\Autocomplete\Maker\MakeAutocompleteField;
35 |
36 | use function Symfony\Component\DependencyInjection\Loader\Configurator\abstract_arg;
37 |
38 | /**
39 | * @author Ryan Weaver
40 | */
41 | final class AutocompleteExtension extends Extension implements PrependExtensionInterface
42 | {
43 | public function prepend(ContainerBuilder $container): void
44 | {
45 | $bundles = $container->getParameter('kernel.bundles');
46 |
47 | if (isset($bundles['TwigBundle'])) {
48 | $container->prependExtensionConfig('twig', [
49 | 'form_themes' => ['@Autocomplete/autocomplete_form_theme.html.twig'],
50 | ]);
51 | }
52 |
53 | if ($this->isAssetMapperAvailable($container)) {
54 | $container->prependExtensionConfig('framework', [
55 | 'asset_mapper' => [
56 | 'paths' => [
57 | __DIR__.'/../../assets/dist' => '@symfony/ux-autocomplete',
58 | ],
59 | ],
60 | ]);
61 | }
62 | }
63 |
64 | public function load(array $configs, ContainerBuilder $container): void
65 | {
66 | $this->registerBasicServices($container);
67 | if (ContainerBuilder::willBeAvailable('symfony/form', Form::class, ['symfony/framework-bundle'])) {
68 | $this->registerFormServices($container);
69 | }
70 | }
71 |
72 | private function registerBasicServices(ContainerBuilder $container): void
73 | {
74 | $container->registerAttributeForAutoconfiguration(AsEntityAutocompleteField::class, function (Definition $definition) {
75 | $definition->addTag(AutocompleteFormTypePass::ENTITY_AUTOCOMPLETE_FIELD_TAG);
76 | });
77 |
78 | $container
79 | ->register('ux.autocomplete.autocompleter_registry', AutocompleterRegistry::class)
80 | ->setArguments([
81 | abstract_arg('autocompleter service locator'),
82 | ]);
83 |
84 | $container
85 | ->register('ux.autocomplete.doctrine_registry_wrapper', DoctrineRegistryWrapper::class)
86 | ->setArguments([
87 | new Reference('doctrine', ContainerInterface::IGNORE_ON_INVALID_REFERENCE),
88 | ])
89 | ;
90 |
91 | $container
92 | ->register('ux.autocomplete.results_executor', AutocompleteResultsExecutor::class)
93 | ->setArguments([
94 | new Reference('ux.autocomplete.doctrine_registry_wrapper'),
95 | new Reference('property_accessor'),
96 | new Reference('security.helper', ContainerInterface::NULL_ON_INVALID_REFERENCE),
97 | ])
98 | ;
99 |
100 | $container
101 | ->register('ux.autocomplete.entity_search_util', EntitySearchUtil::class)
102 | ->setArguments([
103 | new Reference('ux.autocomplete.entity_metadata_factory'),
104 | ])
105 | ;
106 |
107 | $container
108 | ->register('ux.autocomplete.entity_metadata_factory', EntityMetadataFactory::class)
109 | ->setArguments([
110 | new Reference('ux.autocomplete.doctrine_registry_wrapper'),
111 | ])
112 | ;
113 |
114 | $container
115 | ->register('ux.autocomplete.entity_autocomplete_controller', EntityAutocompleteController::class)
116 | ->setArguments([
117 | new Reference('ux.autocomplete.autocompleter_registry'),
118 | new Reference('ux.autocomplete.results_executor'),
119 | new Reference('router'),
120 | new Reference('ux.autocomplete.checksum_calculator'),
121 | ])
122 | ->addTag('controller.service_arguments')
123 | ;
124 |
125 | $container
126 | ->register('ux.autocomplete.make_autocomplete_field', MakeAutocompleteField::class)
127 | ->setArguments([
128 | new Reference('maker.doctrine_helper', ContainerInterface::IGNORE_ON_INVALID_REFERENCE),
129 | ])
130 | ->addTag('maker.command')
131 | ;
132 |
133 | $container
134 | ->register('ux.autocomplete.checksum_calculator', ChecksumCalculator::class)
135 | ->setArguments([
136 | '%kernel.secret%',
137 | ])
138 | ;
139 | }
140 |
141 | private function registerFormServices(ContainerBuilder $container): void
142 | {
143 | $container
144 | ->register('ux.autocomplete.base_entity_type', BaseEntityAutocompleteType::class)
145 | ->setArguments([
146 | new Reference('router'),
147 | ])
148 | ->addTag('form.type');
149 |
150 | $container
151 | ->register('ux.autocomplete.entity_type', ParentEntityAutocompleteType::class)
152 | ->setDeprecated('symfony/ux-autocomplete', '2.13', 'The "%service_id%" form type is deprecated since 2.13. Use "ux.autocomplete.base_entity_type" instead.')
153 | ->setArguments([
154 | new Reference('router'),
155 | ])
156 | ->addTag('form.type');
157 |
158 | $container
159 | ->register('ux.autocomplete.choice_type_extension', AutocompleteChoiceTypeExtension::class)
160 | ->setArguments([
161 | new Reference('ux.autocomplete.checksum_calculator'),
162 | new Reference('translator', ContainerInterface::IGNORE_ON_INVALID_REFERENCE),
163 | ])
164 | ->addTag('form.type_extension');
165 |
166 | $container
167 | ->register('ux.autocomplete.wrapped_entity_type_autocompleter', WrappedEntityTypeAutocompleter::class)
168 | ->setAbstract(true)
169 | ->setArguments([
170 | abstract_arg('form type string'),
171 | new Reference('form.factory'),
172 | new Reference('ux.autocomplete.entity_metadata_factory'),
173 | new Reference('property_accessor'),
174 | new Reference('ux.autocomplete.entity_search_util'),
175 | ]);
176 | }
177 |
178 | private function isAssetMapperAvailable(ContainerBuilder $container): bool
179 | {
180 | if (!interface_exists(AssetMapperInterface::class)) {
181 | return false;
182 | }
183 |
184 | // check that FrameworkBundle 6.3 or higher is installed
185 | $bundlesMetadata = $container->getParameter('kernel.bundles_metadata');
186 | if (!isset($bundlesMetadata['FrameworkBundle'])) {
187 | return false;
188 | }
189 |
190 | return is_file($bundlesMetadata['FrameworkBundle']['path'].'/Resources/config/asset_mapper.php');
191 | }
192 | }
193 |
--------------------------------------------------------------------------------
/src/DependencyInjection/AutocompleteFormTypePass.php:
--------------------------------------------------------------------------------
1 |
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\DependencyInjection;
13 |
14 | use Symfony\Component\DependencyInjection\ChildDefinition;
15 | use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
16 | use Symfony\Component\DependencyInjection\Compiler\ServiceLocatorTagPass;
17 | use Symfony\Component\DependencyInjection\ContainerBuilder;
18 | use Symfony\Component\DependencyInjection\Definition;
19 | use Symfony\Component\DependencyInjection\Reference;
20 | use Symfony\UX\Autocomplete\Form\AsEntityAutocompleteField;
21 |
22 | /**
23 | * @author Ryan Weaver
24 | */
25 | class AutocompleteFormTypePass implements CompilerPassInterface
26 | {
27 | /** @var string Tag applied to form types that will be used for autocompletion */
28 | public const ENTITY_AUTOCOMPLETE_FIELD_TAG = 'ux.entity_autocomplete_field';
29 | /** @var string Tag applied to EntityAutocompleterInterface classes */
30 | public const ENTITY_AUTOCOMPLETER_TAG = 'ux.entity_autocompleter';
31 |
32 | public function process(ContainerBuilder $container): void
33 | {
34 | $this->processEntityAutocompleteFieldTag($container);
35 | $this->processEntityAutocompleterTag($container);
36 | }
37 |
38 | private function processEntityAutocompleteFieldTag(ContainerBuilder $container): void
39 | {
40 | foreach ($container->findTaggedServiceIds(self::ENTITY_AUTOCOMPLETE_FIELD_TAG, true) as $serviceId => $tag) {
41 | $serviceDefinition = $container->getDefinition($serviceId);
42 | if (!$serviceDefinition->hasTag('form.type')) {
43 | throw new \LogicException(\sprintf('Service "%s" has the "%s" tag, but is not tagged with "form.type". Did you add the "%s" attribute to a class that is not a form type?', $serviceId, self::ENTITY_AUTOCOMPLETE_FIELD_TAG, AsEntityAutocompleteField::class));
44 | }
45 | $alias = $this->getAlias($serviceId, $serviceDefinition, $tag);
46 |
47 | $wrappedDefinition = (new ChildDefinition('ux.autocomplete.wrapped_entity_type_autocompleter'))
48 | // the "formType" string
49 | ->replaceArgument(0, $serviceDefinition->getClass())
50 | ->addTag(self::ENTITY_AUTOCOMPLETER_TAG, ['alias' => $alias])
51 | ->addTag('kernel.reset', ['method' => 'reset']);
52 | $container->setDefinition('ux.autocomplete.wrapped_entity_type_autocompleter.'.$alias, $wrappedDefinition);
53 | }
54 | }
55 |
56 | private function getAlias(string $serviceId, Definition $serviceDefinition, array $tag): string
57 | {
58 | if ($tag[0]['alias'] ?? null) {
59 | return $tag[0]['alias'];
60 | }
61 |
62 | $class = $serviceDefinition->getClass();
63 | $attribute = AsEntityAutocompleteField::getInstance($class);
64 | if (null === $attribute) {
65 | throw new \LogicException(\sprintf('The service "%s" either needs to have the #[%s] attribute above its class or its "%s" tag needs an "alias" key.', $serviceId, self::ENTITY_AUTOCOMPLETE_FIELD_TAG, AsEntityAutocompleteField::class));
66 | }
67 |
68 | return $attribute->getAlias() ?: AsEntityAutocompleteField::shortName($class);
69 | }
70 |
71 | private function processEntityAutocompleterTag(ContainerBuilder $container): void
72 | {
73 | $servicesMap = [];
74 | foreach ($container->findTaggedServiceIds(self::ENTITY_AUTOCOMPLETER_TAG, true) as $serviceId => $tag) {
75 | if (!isset($tag[0]['alias'])) {
76 | throw new \LogicException(\sprintf('The "%s" tag of the "%s" service needs "alias" key.', self::ENTITY_AUTOCOMPLETER_TAG, $serviceId));
77 | }
78 |
79 | $servicesMap[$tag[0]['alias']] = new Reference($serviceId);
80 | }
81 |
82 | $definition = $container->findDefinition('ux.autocomplete.autocompleter_registry');
83 | $definition->setArgument(0, ServiceLocatorTagPass::register($container, $servicesMap));
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/src/Doctrine/DoctrineRegistryWrapper.php:
--------------------------------------------------------------------------------
1 |
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\Doctrine;
13 |
14 | use Doctrine\ORM\EntityManagerInterface;
15 | use Doctrine\ORM\EntityRepository;
16 | use Symfony\Bridge\Doctrine\ManagerRegistry;
17 |
18 | /**
19 | * Small wrapper around ManagerRegistry to help if Doctrine is missing.
20 | *
21 | * @author Ryan Weaver
22 | */
23 | class DoctrineRegistryWrapper
24 | {
25 | public function __construct(
26 | private ?ManagerRegistry $registry = null,
27 | ) {
28 | }
29 |
30 | public function getRepository(string $class): EntityRepository
31 | {
32 | return $this->getRegistry()->getRepository($class);
33 | }
34 |
35 | public function getManagerForClass(string $class): EntityManagerInterface
36 | {
37 | return $this->getRegistry()->getManagerForClass($class);
38 | }
39 |
40 | private function getRegistry(): ManagerRegistry
41 | {
42 | if (null === $this->registry) {
43 | throw new \LogicException('Doctrine must be installed to use the entity features of AutocompleteBundle.');
44 | }
45 |
46 | return $this->registry;
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/src/Doctrine/EntityMetadata.php:
--------------------------------------------------------------------------------
1 |
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\Doctrine;
13 |
14 | use Doctrine\ORM\Mapping\AssociationMapping;
15 | use Doctrine\Persistence\Mapping\ClassMetadata;
16 |
17 | /**
18 | * @author Ryan Weaver
19 | */
20 | class EntityMetadata
21 | {
22 | public function __construct(
23 | private ClassMetadata $metadata,
24 | ) {
25 | }
26 |
27 | public function getAllPropertyNames(): array
28 | {
29 | return $this->metadata->getFieldNames();
30 | }
31 |
32 | public function isAssociation(string $propertyName): bool
33 | {
34 | return \array_key_exists($propertyName, $this->metadata->associationMappings)
35 | || (str_contains($propertyName, '.') && !$this->isEmbeddedClassProperty($propertyName));
36 | }
37 |
38 | public function isEmbeddedClassProperty(string $propertyName): bool
39 | {
40 | $propertyNameParts = explode('.', $propertyName, 2);
41 |
42 | return \array_key_exists($propertyNameParts[0], $this->metadata->embeddedClasses);
43 | }
44 |
45 | public function getPropertyMetadata(string $propertyName): array
46 | {
47 | trigger_deprecation('symfony/ux-autocomplete', '2.15.0', 'Calling EntityMetadata::getPropertyMetadata() is deprecated. You should stop using it, as it will be removed in the future.');
48 |
49 | try {
50 | return $this->getFieldMetadata($propertyName);
51 | } catch (\InvalidArgumentException $e) {
52 | return $this->getAssociationMetadata($propertyName);
53 | }
54 | }
55 |
56 | /**
57 | * @internal
58 | *
59 | * @return array
60 | */
61 | public function getFieldMetadata(string $propertyName): array
62 | {
63 | if (\array_key_exists($propertyName, $this->metadata->fieldMappings)) {
64 | // Cast to array, because in doctrine/orm:^3.0; $metadata will be a FieldMapping object
65 | return (array) $this->metadata->fieldMappings[$propertyName];
66 | }
67 |
68 | throw new \InvalidArgumentException(\sprintf('The "%s" field does not exist in the "%s" entity.', $propertyName, $this->metadata->getName()));
69 | }
70 |
71 | /**
72 | * @internal
73 | *
74 | * @return array
75 | */
76 | public function getAssociationMetadata(string $propertyName): array
77 | {
78 | if (\array_key_exists($propertyName, $this->metadata->associationMappings)) {
79 | $associationMapping = $this->metadata->associationMappings[$propertyName];
80 |
81 | // Doctrine ORM 3.0
82 | if (class_exists(AssociationMapping::class) && $associationMapping instanceof AssociationMapping) {
83 | return $associationMapping->toArray();
84 | }
85 |
86 | return $associationMapping;
87 | }
88 |
89 | throw new \InvalidArgumentException(\sprintf('The "%s" field does not exist in the "%s" entity.', $propertyName, $this->metadata->getName()));
90 | }
91 |
92 | public function getPropertyDataType(string $propertyName): string
93 | {
94 | if (\array_key_exists($propertyName, $this->metadata->fieldMappings)) {
95 | return $this->getFieldMetadata($propertyName)['type'];
96 | }
97 |
98 | return $this->getAssociationMetadata($propertyName)['type'];
99 | }
100 |
101 | public function getIdValue(object $entity): string
102 | {
103 | return current($this->metadata->getIdentifierValues($entity));
104 | }
105 | }
106 |
--------------------------------------------------------------------------------
/src/Doctrine/EntityMetadataFactory.php:
--------------------------------------------------------------------------------
1 |
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\Doctrine;
13 |
14 | use Doctrine\Persistence\Mapping\ClassMetadata;
15 | use Doctrine\Persistence\ObjectManager;
16 |
17 | /**
18 | * Adapted from EasyCorp/EasyAdminBundle EntityFactory.
19 | */
20 | class EntityMetadataFactory
21 | {
22 | public function __construct(
23 | private DoctrineRegistryWrapper $doctrine,
24 | ) {
25 | }
26 |
27 | public function create(?string $entityFqcn): EntityMetadata
28 | {
29 | $entityMetadata = $this->getEntityMetadata($entityFqcn);
30 |
31 | return new EntityMetadata($entityMetadata);
32 | }
33 |
34 | private function getEntityMetadata(string $entityFqcn): ClassMetadata
35 | {
36 | $entityManager = $this->getEntityManager($entityFqcn);
37 | $entityMetadata = $entityManager->getClassMetadata($entityFqcn);
38 |
39 | if (1 !== \count($entityMetadata->getIdentifierFieldNames())) {
40 | throw new \RuntimeException(\sprintf('Autocomplete does not support Doctrine entities with composite primary keys (such as the ones used in the "%s" entity).', $entityFqcn));
41 | }
42 |
43 | return $entityMetadata;
44 | }
45 |
46 | private function getEntityManager(string $entityFqcn): ObjectManager
47 | {
48 | if (null === $entityManager = $this->doctrine->getManagerForClass($entityFqcn)) {
49 | throw new \RuntimeException(\sprintf('There is no Doctrine Entity Manager defined for the "%s" class', $entityFqcn));
50 | }
51 |
52 | return $entityManager;
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/src/Doctrine/EntitySearchUtil.php:
--------------------------------------------------------------------------------
1 |
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\Doctrine;
13 |
14 | use Doctrine\ORM\QueryBuilder;
15 | use Symfony\Component\Uid\Ulid;
16 | use Symfony\Component\Uid\Uuid;
17 |
18 | /**
19 | * Adapted from EasyCorp/EasyAdminBundle.
20 | */
21 | class EntitySearchUtil
22 | {
23 | public function __construct(private EntityMetadataFactory $metadataFactory)
24 | {
25 | }
26 |
27 | /**
28 | * Adapted from easycorp/easyadmin EntityRepository.
29 | */
30 | public function addSearchClause(QueryBuilder $queryBuilder, string $query, string $entityClass, ?array $searchableProperties = null): void
31 | {
32 | $entityMetadata = $this->metadataFactory->create($entityClass);
33 |
34 | $lowercaseQuery = mb_strtolower($query);
35 | $isNumericQuery = is_numeric($query);
36 | $isSmallIntegerQuery = ctype_digit($query) && $query >= -32768 && $query <= 32767;
37 | $isIntegerQuery = ctype_digit($query) && $query >= -2147483648 && $query <= 2147483647;
38 | $isUuidQuery = class_exists(Uuid::class) && Uuid::isValid($query);
39 | $isUlidQuery = class_exists(Ulid::class) && Ulid::isValid($query);
40 |
41 | $dqlParameters = [
42 | // adding '0' turns the string into a numeric value
43 | 'numeric_query' => is_numeric($query) ? 0 + $query : $query,
44 | 'uuid_query' => $query,
45 | 'text_query' => '%'.$lowercaseQuery.'%',
46 | 'words_query' => explode(' ', $lowercaseQuery),
47 | ];
48 |
49 | $entitiesAlreadyJoined = [];
50 | $aliasAlreadyUsed = [];
51 | $searchableProperties = empty($searchableProperties) ? $entityMetadata->getAllPropertyNames() : $searchableProperties;
52 | $expressions = [];
53 | foreach ($searchableProperties as $propertyName) {
54 | if ($entityMetadata->isAssociation($propertyName)) {
55 | // support arbitrarily nested associations (e.g. foo.bar.baz.qux)
56 | $associatedProperties = explode('.', $propertyName);
57 | $numAssociatedProperties = \count($associatedProperties);
58 |
59 | if (1 === $numAssociatedProperties) {
60 | throw new \InvalidArgumentException(\sprintf('The "%s" property included in the setSearchFields() method is not a valid search field. When using associated properties in search, you must also define the exact field used in the search (e.g. \'%s.id\', \'%s.name\', etc.)', $propertyName, $propertyName, $propertyName));
61 | }
62 |
63 | $originalPropertyName = $associatedProperties[0];
64 | $originalPropertyMetadata = $entityMetadata->getAssociationMetadata($originalPropertyName);
65 | $associatedEntityDto = $this->metadataFactory->create($originalPropertyMetadata['targetEntity']);
66 |
67 | for ($i = 0; $i < $numAssociatedProperties - 1; ++$i) {
68 | $associatedEntityName = $associatedProperties[$i];
69 | $associatedEntityAlias = SearchEscaper::escapeDqlAlias($associatedEntityName);
70 | $associatedPropertyName = $associatedProperties[$i + 1];
71 |
72 | $associatedParentName = null;
73 | if (\array_key_exists($i - 1, $associatedProperties) && $queryBuilder->getRootAliases()[0] !== $associatedProperties[$i - 1]) {
74 | $associatedParentName = $associatedProperties[$i - 1];
75 | }
76 |
77 | $associatedEntityAlias = $associatedParentName ? $associatedParentName.'_'.$associatedEntityAlias : $associatedEntityAlias;
78 |
79 | if (!\in_array($associatedEntityName, $entitiesAlreadyJoined, true) || !\in_array($associatedEntityAlias, $aliasAlreadyUsed, true)) {
80 | $parentEntityName = 0 === $i ? $queryBuilder->getRootAliases()[0] : $associatedProperties[$i - 1];
81 | $queryBuilder->leftJoin($parentEntityName.'.'.$associatedEntityName, $associatedEntityAlias);
82 | $entitiesAlreadyJoined[] = $associatedEntityName;
83 | $aliasAlreadyUsed[] = $associatedEntityAlias;
84 | }
85 |
86 | if ($i < $numAssociatedProperties - 2) {
87 | $propertyMetadata = $associatedEntityDto->getAssociationMetadata($associatedPropertyName);
88 | $associatedEntityDto = $this->metadataFactory->create($propertyMetadata['targetEntity']);
89 | }
90 | }
91 |
92 | $entityName = $associatedEntityAlias;
93 | $propertyName = $associatedPropertyName;
94 | $propertyDataType = $associatedEntityDto->getPropertyDataType($propertyName);
95 | } else {
96 | $entityName = $queryBuilder->getRootAliases()[0];
97 | $propertyDataType = $entityMetadata->getPropertyDataType($propertyName);
98 | }
99 |
100 | $isSmallIntegerProperty = 'smallint' === $propertyDataType;
101 | $isIntegerProperty = 'integer' === $propertyDataType;
102 | $isNumericProperty = \in_array($propertyDataType, ['number', 'bigint', 'decimal', 'float']);
103 | // 'citext' is a PostgreSQL extension (https://github.com/EasyCorp/EasyAdminBundle/issues/2556)
104 | $isTextProperty = \in_array($propertyDataType, ['string', 'text', 'citext', 'array', 'simple_array']);
105 | $isGuidProperty = \in_array($propertyDataType, ['guid', 'uuid']);
106 | $isUlidProperty = 'ulid' === $propertyDataType;
107 |
108 | // this complex condition is needed to avoid issues on PostgreSQL databases
109 | if (
110 | ($isSmallIntegerProperty && $isSmallIntegerQuery)
111 | || ($isIntegerProperty && $isIntegerQuery)
112 | || ($isNumericProperty && $isNumericQuery)
113 | ) {
114 | $expressions[] = $queryBuilder->expr()->eq(\sprintf('%s.%s', $entityName, $propertyName), ':query_for_numbers');
115 | $queryBuilder->setParameter('query_for_numbers', $dqlParameters['numeric_query']);
116 | } elseif ($isGuidProperty && $isUuidQuery) {
117 | $expressions[] = $queryBuilder->expr()->eq(\sprintf('%s.%s', $entityName, $propertyName), ':query_for_uuids');
118 | $queryBuilder->setParameter('query_for_uuids', $dqlParameters['uuid_query'], 'uuid' === $propertyDataType ? 'uuid' : null);
119 | } elseif ($isUlidProperty && $isUlidQuery) {
120 | $expressions[] = $queryBuilder->expr()->eq(\sprintf('%s.%s', $entityName, $propertyName), ':query_for_uuids');
121 | $queryBuilder->setParameter('query_for_uuids', $dqlParameters['uuid_query'], 'ulid');
122 | } elseif ($isTextProperty) {
123 | $expressions[] = $queryBuilder->expr()->like(\sprintf('LOWER(%s.%s)', $entityName, $propertyName), ':query_for_text');
124 | $queryBuilder->setParameter('query_for_text', $dqlParameters['text_query']);
125 |
126 | $expressions[] = $queryBuilder->expr()->in(\sprintf('LOWER(%s.%s)', $entityName, $propertyName), ':query_as_words');
127 | $queryBuilder->setParameter('query_as_words', $dqlParameters['words_query']);
128 | }
129 | }
130 |
131 | $queryBuilder->andWhere($queryBuilder->expr()->orX(...$expressions));
132 | }
133 | }
134 |
--------------------------------------------------------------------------------
/src/Doctrine/SearchEscaper.php:
--------------------------------------------------------------------------------
1 |
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\Doctrine;
13 |
14 | use Doctrine\ORM\Query\Lexer;
15 |
16 | /**
17 | * Adapted from EasyCorp/EasyAdminBundle Escaper.
18 | */
19 | class SearchEscaper
20 | {
21 | public const DQL_ALIAS_PREFIX = 'autocomplete_';
22 |
23 | /**
24 | * Some words (e.g. "order") are reserved keywords in the DQL (Doctrine Query Language).
25 | * That's why when using entity names as DQL aliases, we need to escape
26 | * those reserved keywords.
27 | *
28 | * This method ensures that the given entity name can be used as a DQL alias.
29 | * Most of them are left unchanged (e.g. "category" or "invoice") but others
30 | * will include a prefix to escape them (e.g. "order" becomes "autocomplete_order").
31 | */
32 | public static function escapeDqlAlias(string $entityName): string
33 | {
34 | if (self::isDqlReservedKeyword($entityName)) {
35 | return self::DQL_ALIAS_PREFIX.$entityName;
36 | }
37 |
38 | return $entityName;
39 | }
40 |
41 | /**
42 | * Determines if a string is a reserved keyword in DQL (Doctrine Query Language).
43 | */
44 | private static function isDqlReservedKeyword(string $string): bool
45 | {
46 | $lexer = new Lexer($string);
47 |
48 | $lexer->moveNext();
49 | $token = $lexer->lookahead;
50 |
51 | // backwards compat for when $token changed from array to object
52 | // https://github.com/doctrine/lexer/pull/79
53 | $type = \is_array($token) ? $token['type'] : $token->type;
54 |
55 | if (200 <= $type) {
56 | return true;
57 | }
58 |
59 | return false;
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/src/EntityAutocompleterInterface.php:
--------------------------------------------------------------------------------
1 |
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 | use Doctrine\ORM\EntityRepository;
15 | use Doctrine\ORM\QueryBuilder;
16 | use Symfony\Bundle\SecurityBundle\Security;
17 |
18 | /**
19 | * Interface for classes that will have an "autocomplete" endpoint exposed.
20 | *
21 | * @template T of object
22 | *
23 | * TODO Remove next lines for Symfony UX 3
24 | *
25 | * @method array getAttributes(object $entity) Returns extra attributes to add to the autocomplete result.
26 | * @method mixed getGroupBy() Return group_by option.
27 | */
28 | interface EntityAutocompleterInterface
29 | {
30 | /**
31 | * The fully-qualified entity class this will be autocompleting.
32 | *
33 | * @return class-string
34 | */
35 | public function getEntityClass(): string;
36 |
37 | /**
38 | * Create a query builder that filters for the given "query".
39 | *
40 | * @param EntityRepository $repository
41 | */
42 | public function createFilteredQueryBuilder(EntityRepository $repository, string $query): QueryBuilder;
43 |
44 | /**
45 | * Returns the "choice_label" used to display this entity.
46 | *
47 | * @param T $entity
48 | */
49 | public function getLabel(object $entity): string;
50 |
51 | /**
52 | * Returns the "value" attribute for this entity, usually the id.
53 | *
54 | * @param T $entity
55 | */
56 | public function getValue(object $entity): mixed;
57 |
58 | /**
59 | * Returns extra attributes to add to the autocomplete result.
60 | *
61 | * TODO Uncomment for Symfony UX 3
62 | */
63 | /* public function getAttributes(object $entity): array; */
64 |
65 | /**
66 | * Return true if access should be granted to the autocomplete results for the current user.
67 | *
68 | * Note: if SecurityBundle is not installed, this will not be called.
69 | */
70 | public function isGranted(Security $security): bool;
71 |
72 | /*
73 | * Return group_by option.
74 | *
75 | * TODO Uncomment for Symfony UX 3
76 | */
77 | /* public function getGroupBy(): mixed; */
78 | }
79 |
--------------------------------------------------------------------------------
/src/Form/AsEntityAutocompleteField.php:
--------------------------------------------------------------------------------
1 |
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\Form;
13 |
14 | /**
15 | * All form types that want to expose autocomplete functionality should have this.
16 | *
17 | * @author Ryan Weaver
18 | */
19 | #[\Attribute(\Attribute::TARGET_CLASS)]
20 | class AsEntityAutocompleteField
21 | {
22 | public function __construct(
23 | private ?string $alias = null,
24 | private string $route = 'ux_entity_autocomplete',
25 | ) {
26 | }
27 |
28 | public function getAlias(): ?string
29 | {
30 | return $this->alias;
31 | }
32 |
33 | public function getRoute(): string
34 | {
35 | return $this->route;
36 | }
37 |
38 | /**
39 | * @internal
40 | *
41 | * @param class-string $class
42 | */
43 | public static function shortName(string $class): string
44 | {
45 | if ($pos = (int) strrpos($class, '\\')) {
46 | $class = substr($class, $pos + 1);
47 | }
48 |
49 | return strtolower(preg_replace('/([a-z])([A-Z])/', '$1_$2', $class));
50 | }
51 |
52 | /**
53 | * @internal
54 | *
55 | * @param class-string $class
56 | */
57 | public static function getInstance(string $class): ?self
58 | {
59 | $reflectionClass = new \ReflectionClass($class);
60 |
61 | $attributes = $reflectionClass->getAttributes(self::class);
62 |
63 | if (0 === \count($attributes)) {
64 | return null;
65 | }
66 |
67 | return $attributes[0]->newInstance();
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/src/Form/AutocompleteChoiceTypeExtension.php:
--------------------------------------------------------------------------------
1 |
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\Form;
13 |
14 | use Symfony\Component\Form\AbstractTypeExtension;
15 | use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
16 | use Symfony\Component\Form\Extension\Core\Type\TextType;
17 | use Symfony\Component\Form\FormInterface;
18 | use Symfony\Component\Form\FormView;
19 | use Symfony\Component\OptionsResolver\Options;
20 | use Symfony\Component\OptionsResolver\OptionsResolver;
21 | use Symfony\Contracts\Translation\TranslatorInterface;
22 | use Symfony\UX\Autocomplete\Checksum\ChecksumCalculator;
23 |
24 | /**
25 | * Initializes the autocomplete Stimulus controller for any fields with the "autocomplete" option.
26 | *
27 | * @internal
28 | */
29 | final class AutocompleteChoiceTypeExtension extends AbstractTypeExtension
30 | {
31 | public const CHECKSUM_KEY = '@checksum';
32 |
33 | public function __construct(
34 | private readonly ChecksumCalculator $checksumCalculator,
35 | private readonly ?TranslatorInterface $translator = null,
36 | ) {
37 | }
38 |
39 | public static function getExtendedTypes(): iterable
40 | {
41 | return [
42 | ChoiceType::class,
43 | TextType::class,
44 | ];
45 | }
46 |
47 | public function finishView(FormView $view, FormInterface $form, array $options): void
48 | {
49 | if (!$options['autocomplete']) {
50 | $view->vars['uses_autocomplete'] = false;
51 |
52 | return;
53 | }
54 |
55 | $attr = $view->vars['attr'] ?? [];
56 |
57 | $controllerName = 'symfony--ux-autocomplete--autocomplete';
58 | $attr['data-controller'] = trim(($attr['data-controller'] ?? '').' '.$controllerName);
59 |
60 | $values = [];
61 | if ($options['autocomplete_url']) {
62 | $values['url'] = $options['autocomplete_url'];
63 | } elseif ($form->getConfig()->hasAttribute('autocomplete_url')) {
64 | $values['url'] = $form->getConfig()->getAttribute('autocomplete_url');
65 | }
66 |
67 | if ($options['options_as_html']) {
68 | $values['options-as-html'] = '';
69 | }
70 |
71 | if ($options['allow_options_create']) {
72 | $values['allow-options-create'] = '';
73 | }
74 |
75 | if ($options['tom_select_options']) {
76 | $values['tom-select-options'] = json_encode($options['tom_select_options']);
77 | }
78 |
79 | if ($options['max_results']) {
80 | $values['max-results'] = $options['max_results'];
81 | }
82 |
83 | if ($options['min_characters']) {
84 | $values['min-characters'] = $options['min_characters'];
85 | }
86 |
87 | if ($options['extra_options']) {
88 | $values['url'] = $this->getUrlWithExtraOptions($values['url'], $options['extra_options']);
89 | }
90 |
91 | $values['loading-more-text'] = $this->trans($options['loading_more_text']);
92 | $values['no-results-found-text'] = $this->trans($options['no_results_found_text']);
93 | $values['no-more-results-text'] = $this->trans($options['no_more_results_text']);
94 | $values['create-option-text'] = $this->trans($options['create_option_text']);
95 | $values['preload'] = $options['preload'];
96 |
97 | foreach ($values as $name => $value) {
98 | $attr['data-'.$controllerName.'-'.$name.'-value'] = $value;
99 | }
100 |
101 | $view->vars['uses_autocomplete'] = true;
102 | $view->vars['attr'] = $attr;
103 | }
104 |
105 | private function getUrlWithExtraOptions(string $url, array $extraOptions): string
106 | {
107 | $this->validateExtraOptions($extraOptions);
108 |
109 | $extraOptions[self::CHECKSUM_KEY] = $this->checksumCalculator->calculateForArray($extraOptions);
110 | $extraOptions = base64_encode(json_encode($extraOptions));
111 |
112 | return \sprintf(
113 | '%s%s%s',
114 | $url,
115 | $this->hasUrlParameters($url) ? '&' : '?',
116 | http_build_query(['extra_options' => $extraOptions]),
117 | );
118 | }
119 |
120 | private function hasUrlParameters(string $url): bool
121 | {
122 | $parsedUrl = parse_url($url);
123 |
124 | return isset($parsedUrl['query']);
125 | }
126 |
127 | private function validateExtraOptions(array $extraOptions): void
128 | {
129 | foreach ($extraOptions as $optionKey => $option) {
130 | if (!\is_scalar($option) && !\is_array($option) && null !== $option) {
131 | 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)));
132 | }
133 |
134 | if (\is_array($option)) {
135 | $this->validateExtraOptions($option);
136 | }
137 | }
138 | }
139 |
140 | public function configureOptions(OptionsResolver $resolver): void
141 | {
142 | $resolver->setDefaults([
143 | 'autocomplete' => false,
144 | 'autocomplete_url' => null,
145 | 'tom_select_options' => [],
146 | 'options_as_html' => false,
147 | 'allow_options_create' => false,
148 | 'loading_more_text' => 'Loading more results...',
149 | 'no_results_found_text' => 'No results found',
150 | 'no_more_results_text' => 'No more results',
151 | 'create_option_text' => 'Add %placeholder%...',
152 | 'min_characters' => null,
153 | 'max_results' => 10,
154 | 'preload' => 'focus',
155 | 'extra_options' => [],
156 | ]);
157 |
158 | // if autocomplete_url is passed, then HTML options are already supported
159 | $resolver->setNormalizer('options_as_html', function (Options $options, $value) {
160 | return null === $options['autocomplete_url'] ? $value : false;
161 | });
162 |
163 | $resolver->setNormalizer('preload', function (Options $options, $value) {
164 | if (\is_bool($value)) {
165 | $value = $value ? 'true' : 'false';
166 | }
167 |
168 | return $value;
169 | });
170 | }
171 |
172 | private function trans(string $message): string
173 | {
174 | return $this->translator ? $this->translator->trans($message, [], 'AutocompleteBundle') : $message;
175 | }
176 | }
177 |
--------------------------------------------------------------------------------
/src/Form/AutocompleteEntityTypeSubscriber.php:
--------------------------------------------------------------------------------
1 |
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\Form;
13 |
14 | use Doctrine\ORM\EntityManagerInterface;
15 | use Doctrine\ORM\Utility\PersisterHelper;
16 | use Symfony\Bridge\Doctrine\Form\Type\EntityType;
17 | use Symfony\Component\EventDispatcher\EventSubscriberInterface;
18 | use Symfony\Component\Form\FormEvent;
19 | use Symfony\Component\Form\FormEvents;
20 |
21 | /**
22 | * Helps transform ParentEntityAutocompleteType into a EntityType that will not load all options.
23 | *
24 | * @internal
25 | *
26 | * @deprecated since UX 2.13
27 | */
28 | final class AutocompleteEntityTypeSubscriber implements EventSubscriberInterface
29 | {
30 | public function __construct(
31 | private ?string $autocompleteUrl = null,
32 | ) {
33 | }
34 |
35 | public function preSetData(FormEvent $event)
36 | {
37 | $form = $event->getForm();
38 | $data = $event->getData() ?: [];
39 |
40 | $options = $form->getConfig()->getOptions();
41 | $options['compound'] = false;
42 | $options['choices'] = is_iterable($data) ? $data : [$data];
43 | // pass to AutocompleteChoiceTypeExtension
44 | $options['autocomplete'] = true;
45 | $options['autocomplete_url'] = $this->autocompleteUrl;
46 | unset($options['searchable_fields'], $options['security'], $options['filter_query']);
47 |
48 | $form->add('autocomplete', EntityType::class, $options);
49 | }
50 |
51 | public function preSubmit(FormEvent $event)
52 | {
53 | $data = $event->getData();
54 | $form = $event->getForm();
55 | $options = $form->get('autocomplete')->getConfig()->getOptions();
56 |
57 | /** @var EntityManagerInterface $em */
58 | $em = $options['em'];
59 | $repository = $em->getRepository($options['class']);
60 | $queryBuilder = $options['query_builder'] ?: $repository->createQueryBuilder('o');
61 | $rootAlias = $queryBuilder->getRootAliases()[0];
62 |
63 | if (!isset($data['autocomplete']) || '' === $data['autocomplete']) {
64 | $options['choices'] = [];
65 | } else {
66 | $idField = $options['id_reader']->getIdField();
67 | $idType = PersisterHelper::getTypeOfField($idField, $em->getClassMetadata($options['class']), $em)[0];
68 |
69 | if ($options['multiple']) {
70 | $params = [];
71 | $idx = 0;
72 |
73 | foreach ($data['autocomplete'] as $id) {
74 | $params[":id_$idx"] = [$id, $idType];
75 | ++$idx;
76 | }
77 |
78 | if ($params) {
79 | $queryBuilder
80 | ->andWhere(\sprintf("$rootAlias.$idField IN (%s)", implode(', ', array_keys($params))))
81 | ;
82 | foreach ($params as $key => $param) {
83 | $queryBuilder->setParameter($key, $param[0], $param[1]);
84 | }
85 | }
86 |
87 | $options['choices'] = $queryBuilder->getQuery()->getResult();
88 | } else {
89 | $options['choices'] = $queryBuilder
90 | ->andWhere("$rootAlias.$idField = :id")
91 | ->setParameter('id', $data['autocomplete'], $idType)
92 | ->getQuery()
93 | ->getResult();
94 | }
95 | }
96 |
97 | // reset some critical lazy options
98 | unset($options['em'], $options['loader'], $options['empty_data'], $options['choice_list'], $options['choices_as_values']);
99 |
100 | $form->add('autocomplete', EntityType::class, $options);
101 | }
102 |
103 | public static function getSubscribedEvents(): array
104 | {
105 | return [
106 | FormEvents::PRE_SET_DATA => 'preSetData',
107 | FormEvents::PRE_SUBMIT => 'preSubmit',
108 | ];
109 | }
110 | }
111 |
--------------------------------------------------------------------------------
/src/Form/BaseEntityAutocompleteType.php:
--------------------------------------------------------------------------------
1 |
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\Form;
13 |
14 | use Symfony\Bridge\Doctrine\Form\Type\EntityType;
15 | use Symfony\Component\Form\AbstractType;
16 | use Symfony\Component\Form\ChoiceList\Loader\LazyChoiceLoader;
17 | use Symfony\Component\Form\Exception\RuntimeException;
18 | use Symfony\Component\Form\FormBuilderInterface;
19 | use Symfony\Component\OptionsResolver\Options;
20 | use Symfony\Component\OptionsResolver\OptionsResolver;
21 | use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
22 | use Symfony\UX\Autocomplete\Form\ChoiceList\Loader\ExtraLazyChoiceLoader;
23 |
24 | /**
25 | * All form types that want to expose autocomplete functionality should use this for its getParent().
26 | */
27 | final class BaseEntityAutocompleteType extends AbstractType
28 | {
29 | public function __construct(
30 | private UrlGeneratorInterface $urlGenerator,
31 | ) {
32 | }
33 |
34 | public function buildForm(FormBuilderInterface $builder, array $options): void
35 | {
36 | $builder->setAttribute('autocomplete_url', $this->getAutocompleteUrl($builder, $options));
37 | }
38 |
39 | public function configureOptions(OptionsResolver $resolver): void
40 | {
41 | $choiceLoader = static function (Options $options, $loader) {
42 | if (null === $loader) {
43 | return null;
44 | }
45 |
46 | if (class_exists(LazyChoiceLoader::class)) {
47 | return new LazyChoiceLoader($loader);
48 | }
49 |
50 | return new ExtraLazyChoiceLoader($loader);
51 | };
52 |
53 | $resolver->setDefaults([
54 | 'autocomplete' => true,
55 | 'choice_loader' => $choiceLoader,
56 | // set to the fields to search on or null to search on all fields
57 | 'searchable_fields' => null,
58 | // override the search logic - set to a callable:
59 | // function(QueryBuilder $qb, string $query, EntityRepository $repository) {
60 | // $qb->andWhere('entity.name LIKE :filter OR entity.description LIKE :filter')
61 | // ->setParameter('filter', '%'.$query.'%');
62 | // }
63 | 'filter_query' => null,
64 | // set to the string role that's required to view the autocomplete results
65 | // or a callable: function(Symfony\Component\Security\Core\Security $security): bool
66 | 'security' => false,
67 | // set the max results number that a query on automatic endpoint return.
68 | 'max_results' => 10,
69 | ]);
70 |
71 | $resolver->setAllowedTypes('security', ['boolean', 'string', 'callable']);
72 | $resolver->setAllowedTypes('max_results', ['int', 'null']);
73 | $resolver->setAllowedTypes('filter_query', ['callable', 'null']);
74 | $resolver->setNormalizer('searchable_fields', function (Options $options, ?array $searchableFields) {
75 | if (null !== $searchableFields && null !== $options['filter_query']) {
76 | throw new RuntimeException('Both the searchable_fields and filter_query options cannot be set.');
77 | }
78 |
79 | return $searchableFields;
80 | });
81 | }
82 |
83 | public function getParent(): string
84 | {
85 | return EntityType::class;
86 | }
87 |
88 | public function getBlockPrefix(): string
89 | {
90 | return 'ux_entity_autocomplete';
91 | }
92 |
93 | /**
94 | * Uses the provided URL, or auto-generate from the provided alias.
95 | */
96 | private function getAutocompleteUrl(FormBuilderInterface $builder, array $options): string
97 | {
98 | if ($options['autocomplete_url']) {
99 | return $options['autocomplete_url'];
100 | }
101 |
102 | $formType = $builder->getType()->getInnerType();
103 | $attribute = AsEntityAutocompleteField::getInstance($formType::class);
104 |
105 | if (!$attribute) {
106 | throw new \LogicException(\sprintf('You must either provide your own autocomplete_url, or add #[AsEntityAutocompleteField] attribute to "%s".', $formType::class));
107 | }
108 |
109 | return $this->urlGenerator->generate($attribute->getRoute(), [
110 | 'alias' => $attribute->getAlias() ?: AsEntityAutocompleteField::shortName($formType::class),
111 | ]);
112 | }
113 | }
114 |
--------------------------------------------------------------------------------
/src/Form/ChoiceList/Loader/ExtraLazyChoiceLoader.php:
--------------------------------------------------------------------------------
1 |
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\Form\ChoiceList\Loader;
13 |
14 | use Symfony\Component\Form\ChoiceList\ArrayChoiceList;
15 | use Symfony\Component\Form\ChoiceList\ChoiceListInterface;
16 | use Symfony\Component\Form\ChoiceList\Loader\ChoiceLoaderInterface;
17 |
18 | /**
19 | * Loads choices on demand only.
20 | *
21 | * @deprecated since Autocomplete 2.23 and will be removed in 3.0, use `Symfony\Component\Form\ChoiceList\Loader\LazyChoiceLoader` instead.
22 | */
23 | class ExtraLazyChoiceLoader implements ChoiceLoaderInterface
24 | {
25 | private ?ChoiceListInterface $choiceList = null;
26 |
27 | public function __construct(
28 | private readonly ChoiceLoaderInterface $decorated,
29 | ) {
30 | }
31 |
32 | public function loadChoiceList(?callable $value = null): ChoiceListInterface
33 | {
34 | return $this->choiceList ??= new ArrayChoiceList([], $value);
35 | }
36 |
37 | public function loadChoicesForValues(array $values, ?callable $value = null): array
38 | {
39 | $choices = $this->decorated->loadChoicesForValues($values, $value);
40 | $this->choiceList = new ArrayChoiceList($choices, $value);
41 |
42 | return $choices;
43 | }
44 |
45 | public function loadValuesForChoices(array $choices, ?callable $value = null): array
46 | {
47 | $values = $this->decorated->loadValuesForChoices($choices, $value);
48 | $this->loadChoicesForValues($values, $value);
49 |
50 | return $values;
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/src/Form/ParentEntityAutocompleteType.php:
--------------------------------------------------------------------------------
1 |
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\Form;
13 |
14 | use Symfony\Component\Form\AbstractType;
15 | use Symfony\Component\Form\DataMapperInterface;
16 | use Symfony\Component\Form\Exception\RuntimeException;
17 | use Symfony\Component\Form\FormBuilderInterface;
18 | use Symfony\Component\Form\FormInterface;
19 | use Symfony\Component\Form\FormView;
20 | use Symfony\Component\OptionsResolver\Options;
21 | use Symfony\Component\OptionsResolver\OptionsResolver;
22 | use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
23 |
24 | /**
25 | * All form types that want to expose autocomplete functionality should use this for its getParent().
26 | *
27 | * @deprecated since UX 2.13, use "Symfony\UX\Autocomplete\Form\BaseEntityAutocompleteType" instead
28 | */
29 | final class ParentEntityAutocompleteType extends AbstractType implements DataMapperInterface
30 | {
31 | public function __construct(
32 | private UrlGeneratorInterface $urlGenerator,
33 | ) {
34 | }
35 |
36 | public function buildForm(FormBuilderInterface $builder, array $options)
37 | {
38 | $formType = $builder->getType()->getInnerType();
39 | $attribute = AsEntityAutocompleteField::getInstance($formType::class);
40 |
41 | if (!$attribute && empty($options['autocomplete_url'])) {
42 | throw new \LogicException(\sprintf('You must either provide your own autocomplete_url, or add #[AsEntityAutocompleteField] attribute to "%s".', $formType::class));
43 | }
44 |
45 | // Use the provided URL, or auto-generate from the provided alias
46 | $autocompleteUrl = $options['autocomplete_url'] ?? $this->urlGenerator->generate($attribute->getRoute(), [
47 | 'alias' => $attribute->getAlias() ?: AsEntityAutocompleteField::shortName($formType::class),
48 | ]);
49 |
50 | $builder
51 | ->addEventSubscriber(new AutocompleteEntityTypeSubscriber($autocompleteUrl))
52 | ->setDataMapper($this);
53 | }
54 |
55 | public function finishView(FormView $view, FormInterface $form, array $options)
56 | {
57 | // Add a custom block prefix to inner field to ease theming:
58 | array_splice($view['autocomplete']->vars['block_prefixes'], -1, 0, 'ux_entity_autocomplete_inner');
59 | // this IS A compound (i.e. has children) field
60 | // however, we only render the child "autocomplete" field. So for rendering, fake NOT compound
61 | // This is a hack and we should check into removing it in the future
62 | $view->vars['compound'] = false;
63 | // the above, unfortunately, can also trick other things that might use
64 | // "compound" for other reasons. This, at least, leaves a hint.
65 | $view->vars['compound_data'] = true;
66 | }
67 |
68 | public function configureOptions(OptionsResolver $resolver)
69 | {
70 | $resolver->setDefaults([
71 | 'multiple' => false,
72 | // force display errors on this form field
73 | 'error_bubbling' => false,
74 | // set to the fields to search on or null to search on all fields
75 | 'searchable_fields' => null,
76 | // override the search logic - set to a callable:
77 | // function(QueryBuilder $qb, string $query, EntityRepository $repository) {
78 | // $qb->andWhere('entity.name LIKE :filter OR entity.description LIKE :filter')
79 | // ->setParameter('filter', '%'.$query.'%');
80 | // }
81 | 'filter_query' => null,
82 | // set to the string role that's required to view the autocomplete results
83 | // or a callable: function(Symfony\Bundle\SecurityBundle\Security $security): bool
84 | 'security' => false,
85 | // set the max results number that a query on automatic endpoint return.
86 | 'max_results' => 10,
87 | ]);
88 |
89 | $resolver->setRequired(['class']);
90 | $resolver->setAllowedTypes('security', ['boolean', 'string', 'callable']);
91 | $resolver->setAllowedTypes('max_results', ['int', 'null']);
92 | $resolver->setAllowedTypes('filter_query', ['callable', 'null']);
93 | $resolver->setNormalizer('searchable_fields', function (Options $options, ?array $searchableFields) {
94 | if (null !== $searchableFields && null !== $options['filter_query']) {
95 | throw new RuntimeException('Both the searchable_fields and filter_query options cannot be set.');
96 | }
97 |
98 | return $searchableFields;
99 | });
100 | }
101 |
102 | public function getBlockPrefix(): string
103 | {
104 | return 'ux_entity_autocomplete';
105 | }
106 |
107 | public function mapDataToForms($data, $forms): void
108 | {
109 | $form = current(iterator_to_array($forms, false));
110 | $form->setData($data);
111 | }
112 |
113 | public function mapFormsToData($forms, &$data): void
114 | {
115 | $form = current(iterator_to_array($forms, false));
116 | $data = $form->getData();
117 | }
118 | }
119 |
--------------------------------------------------------------------------------
/src/Form/WrappedEntityTypeAutocompleter.php:
--------------------------------------------------------------------------------
1 |
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\Form;
13 |
14 | use Doctrine\ORM\EntityRepository;
15 | use Doctrine\ORM\QueryBuilder;
16 | use Symfony\Bundle\SecurityBundle\Security;
17 | use Symfony\Component\Form\ChoiceList\Factory\Cache\ChoiceLabel;
18 | use Symfony\Component\Form\FormFactoryInterface;
19 | use Symfony\Component\Form\FormInterface;
20 | use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
21 | use Symfony\Component\PropertyAccess\PropertyPathInterface;
22 | use Symfony\Contracts\Service\ResetInterface;
23 | use Symfony\UX\Autocomplete\Doctrine\EntityMetadata;
24 | use Symfony\UX\Autocomplete\Doctrine\EntityMetadataFactory;
25 | use Symfony\UX\Autocomplete\Doctrine\EntitySearchUtil;
26 | use Symfony\UX\Autocomplete\OptionsAwareEntityAutocompleterInterface;
27 |
28 | /**
29 | * An entity auto-completer that wraps a form type to get its information.
30 | *
31 | * @internal
32 | */
33 | final class WrappedEntityTypeAutocompleter implements OptionsAwareEntityAutocompleterInterface, ResetInterface
34 | {
35 | private ?FormInterface $form = null;
36 | private ?EntityMetadata $entityMetadata = null;
37 | private array $options = [];
38 |
39 | public function __construct(
40 | private string $formType,
41 | private FormFactoryInterface $formFactory,
42 | private EntityMetadataFactory $metadataFactory,
43 | private PropertyAccessorInterface $propertyAccessor,
44 | private EntitySearchUtil $entitySearchUtil,
45 | ) {
46 | }
47 |
48 | public function getEntityClass(): string
49 | {
50 | return $this->getFormOption('class');
51 | }
52 |
53 | public function createFilteredQueryBuilder(EntityRepository $repository, string $query): QueryBuilder
54 | {
55 | $queryBuilder = $this->getFormOption('query_builder');
56 | $queryBuilder = $queryBuilder ?: $repository->createQueryBuilder('entity');
57 |
58 | if ($filterQuery = $this->getFilterQuery()) {
59 | $filterQuery($queryBuilder, $query, $repository);
60 |
61 | return $queryBuilder;
62 | }
63 |
64 | // Applying max result limit or not
65 | $queryBuilder->setMaxResults($this->getMaxResults());
66 |
67 | // avoid filtering if there is no query
68 | if (!$query) {
69 | return $queryBuilder;
70 | }
71 |
72 | $this->entitySearchUtil->addSearchClause(
73 | $queryBuilder,
74 | $query,
75 | $this->getEntityClass(),
76 | $this->getSearchableFields()
77 | );
78 |
79 | return $queryBuilder;
80 | }
81 |
82 | public function getLabel(object $entity): string
83 | {
84 | $choiceLabel = $this->getFormOption('choice_label');
85 |
86 | if (null === $choiceLabel) {
87 | return (string) $entity;
88 | }
89 |
90 | if (\is_string($choiceLabel) || $choiceLabel instanceof PropertyPathInterface) {
91 | return $this->propertyAccessor->getValue($entity, $choiceLabel);
92 | }
93 |
94 | if ($choiceLabel instanceof ChoiceLabel) {
95 | $choiceLabel = $choiceLabel->getOption();
96 | }
97 |
98 | // 0 hardcoded as the "index", should not be relevant
99 | return $choiceLabel($entity, 0, $this->getValue($entity));
100 | }
101 |
102 | public function getValue(object $entity): string
103 | {
104 | $choiceValue = $this->getFormOption('choice_value');
105 |
106 | if (\is_string($choiceValue) || $choiceValue instanceof PropertyPathInterface) {
107 | return $this->propertyAccessor->getValue($entity, $choiceValue);
108 | }
109 |
110 | if ($choiceValue instanceof \Closure) {
111 | return $choiceValue($entity);
112 | }
113 |
114 | return $this->getEntityMetadata()->getIdValue($entity);
115 | }
116 |
117 | public function isGranted(Security $security): bool
118 | {
119 | $securityOption = $this->getForm()->getConfig()->getOption('security');
120 |
121 | if (false === $securityOption) {
122 | return true;
123 | }
124 |
125 | if (\is_string($securityOption)) {
126 | return $security->isGranted($securityOption, $this);
127 | }
128 |
129 | if (\is_callable($securityOption)) {
130 | return $securityOption($security);
131 | }
132 |
133 | throw new \InvalidArgumentException('Invalid passed to the "security" option: it must be the boolean true, a string role or a callable.');
134 | }
135 |
136 | public function getGroupBy(): mixed
137 | {
138 | return $this->getFormOption('group_by');
139 | }
140 |
141 | private function getFormOption(string $name): mixed
142 | {
143 | $form = $this->getForm();
144 | // Remove when dropping support for ParentEntityAutocompleteType
145 | $form = $form->has('autocomplete') ? $form->get('autocomplete') : $form;
146 | $formOptions = $form->getConfig()->getOptions();
147 |
148 | return $formOptions[$name] ?? null;
149 | }
150 |
151 | private function getForm(): FormInterface
152 | {
153 | if (null === $this->form) {
154 | $this->form = $this->formFactory->create($this->formType, options: $this->options);
155 | }
156 |
157 | return $this->form;
158 | }
159 |
160 | private function getSearchableFields(): ?array
161 | {
162 | return $this->getForm()->getConfig()->getOption('searchable_fields');
163 | }
164 |
165 | private function getFilterQuery(): ?callable
166 | {
167 | return $this->getForm()->getConfig()->getOption('filter_query');
168 | }
169 |
170 | private function getMaxResults(): ?int
171 | {
172 | return $this->getForm()->getConfig()->getOption('max_results');
173 | }
174 |
175 | private function getEntityMetadata(): EntityMetadata
176 | {
177 | if (null === $this->entityMetadata) {
178 | $this->entityMetadata = $this->metadataFactory->create($this->getEntityClass());
179 | }
180 |
181 | return $this->entityMetadata;
182 | }
183 |
184 | public function setOptions(array $options): void
185 | {
186 | if (null !== $this->form) {
187 | throw new \LogicException('The options can only be set before the form is created.');
188 | }
189 |
190 | $this->options = $options;
191 | }
192 |
193 | public function reset(): void
194 | {
195 | unset($this->form);
196 | $this->form = null;
197 | }
198 | }
199 |
--------------------------------------------------------------------------------
/src/Maker/MakeAutocompleteField.php:
--------------------------------------------------------------------------------
1 |
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\Maker;
13 |
14 | use Doctrine\ORM\EntityManagerInterface;
15 | use Symfony\Bundle\MakerBundle\ConsoleStyle;
16 | use Symfony\Bundle\MakerBundle\DependencyBuilder;
17 | use Symfony\Bundle\MakerBundle\Doctrine\DoctrineHelper;
18 | use Symfony\Bundle\MakerBundle\Generator;
19 | use Symfony\Bundle\MakerBundle\InputConfiguration;
20 | use Symfony\Bundle\MakerBundle\Maker\AbstractMaker;
21 | use Symfony\Bundle\MakerBundle\Str;
22 | use Symfony\Bundle\MakerBundle\Util\UseStatementGenerator;
23 | use Symfony\Bundle\MakerBundle\Validator;
24 | use Symfony\Component\Console\Command\Command;
25 | use Symfony\Component\Console\Input\InputInterface;
26 | use Symfony\Component\Console\Question\Question;
27 | use Symfony\Component\Form\AbstractType;
28 | use Symfony\Component\Form\FormInterface;
29 | use Symfony\Component\OptionsResolver\OptionsResolver;
30 | use Symfony\UX\Autocomplete\Form\AsEntityAutocompleteField;
31 | use Symfony\UX\Autocomplete\Form\BaseEntityAutocompleteType;
32 |
33 | /**
34 | * @author Ryan Weaver
35 | */
36 | class MakeAutocompleteField extends AbstractMaker
37 | {
38 | private string $className;
39 | private string $entityClass;
40 |
41 | public function __construct(
42 | private ?DoctrineHelper $doctrineHelper = null,
43 | ) {
44 | }
45 |
46 | public static function getCommandName(): string
47 | {
48 | return 'make:autocomplete-field';
49 | }
50 |
51 | public static function getCommandDescription(): string
52 | {
53 | return 'Generates an Ajax-autocomplete form field class for symfony/ux-autocomplete.';
54 | }
55 |
56 | public function configureCommand(Command $command, InputConfiguration $inputConfig): void
57 | {
58 | $command
59 | ->setHelp(<<%command.name% command generates an Ajax-autocomplete form field class for symfony/ux-autocomplete
61 |
62 | php %command.full_name%
63 |
64 | The command will ask you which entity the field is for and what to call your new class.
65 | EOF)
66 | ;
67 | }
68 |
69 | public function configureDependencies(DependencyBuilder $dependencies): void
70 | {
71 | $dependencies->addClassDependency(FormInterface::class, 'symfony/form');
72 | }
73 |
74 | public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void
75 | {
76 | if (null === $this->doctrineHelper) {
77 | throw new \LogicException('Somehow the DoctrineHelper service is missing from MakerBundle.');
78 | }
79 |
80 | $entities = $this->doctrineHelper->getEntitiesForAutocomplete();
81 |
82 | $question = new Question('The class name of the entity you want to autocomplete');
83 | $question->setAutocompleterValues($entities);
84 | $question->setValidator(function ($choice) use ($entities) {
85 | return Validator::entityExists($choice, $entities);
86 | });
87 |
88 | $this->entityClass = $io->askQuestion($question);
89 |
90 | $defaultClass = Str::asClassName(\sprintf('%s AutocompleteField', $this->entityClass));
91 | $this->className = $io->ask(
92 | \sprintf('Choose a name for your entity field class (e.g. %s>)', $defaultClass),
93 | $defaultClass
94 | );
95 | }
96 |
97 | public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void
98 | {
99 | if (null === $this->doctrineHelper) {
100 | throw new \LogicException('Somehow the DoctrineHelper service is missing from MakerBundle.');
101 | }
102 |
103 | $entityClassDetails = $generator->createClassNameDetails(
104 | $this->entityClass,
105 | 'Entity\\'
106 | );
107 | $entityDoctrineDetails = $this->doctrineHelper->createDoctrineDetails($entityClassDetails->getFullName());
108 |
109 | $classDetails = $generator->createClassNameDetails(
110 | $this->className,
111 | 'Form\\',
112 | );
113 |
114 | $repositoryClassDetails = $entityDoctrineDetails->getRepositoryClass() ? $generator->createClassNameDetails('\\'.$entityDoctrineDetails->getRepositoryClass(), '') : null;
115 |
116 | // use App\Entity\Category;
117 | // use App\Repository\CategoryRepository;
118 | $useStatements = new UseStatementGenerator([
119 | $entityClassDetails->getFullName(),
120 | $repositoryClassDetails ? $repositoryClassDetails->getFullName() : EntityManagerInterface::class,
121 | AbstractType::class,
122 | OptionsResolver::class,
123 | AsEntityAutocompleteField::class,
124 | BaseEntityAutocompleteType::class,
125 | ]);
126 |
127 | $variables = new MakerAutocompleteVariables(
128 | useStatements: $useStatements,
129 | entityClassDetails: $entityClassDetails,
130 | repositoryClassDetails: $repositoryClassDetails,
131 | );
132 | $generator->generateClass(
133 | $classDetails->getFullName(),
134 | __DIR__.'/skeletons/AutocompleteField.tpl.php',
135 | [
136 | 'variables' => $variables,
137 | ]
138 | );
139 |
140 | $generator->writeChanges();
141 |
142 | $this->writeSuccessMessage($io);
143 |
144 | $io->text([
145 | 'Customize your new field class, then add it to a form:',
146 | '',
147 | ' $builder ',
148 | ' // ... ',
149 | \sprintf(' ->add(\'%s\', %s::class) ', Str::asLowerCamelCase($entityClassDetails->getShortName()), $classDetails->getShortName()),
150 | ' ;>',
151 | ]);
152 | }
153 | }
154 |
--------------------------------------------------------------------------------
/src/Maker/MakerAutocompleteVariables.php:
--------------------------------------------------------------------------------
1 |
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\Maker;
13 |
14 | use Symfony\Bundle\MakerBundle\Util\ClassNameDetails;
15 | use Symfony\Bundle\MakerBundle\Util\UseStatementGenerator;
16 |
17 | /**
18 | * @internal
19 | */
20 | class MakerAutocompleteVariables
21 | {
22 | public function __construct(
23 | public UseStatementGenerator $useStatements,
24 | public ClassNameDetails $entityClassDetails,
25 | public ?ClassNameDetails $repositoryClassDetails = null,
26 | ) {
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/src/Maker/skeletons/AutocompleteField.tpl.php:
--------------------------------------------------------------------------------
1 |
10 |
11 | namespace ;
12 |
13 | useStatements; ?>
14 |
15 | #[AsEntityAutocompleteField]
16 | class extends AbstractType
17 | {
18 | public function configureOptions(OptionsResolver $resolver): void
19 | {
20 | $resolver->setDefaults([
21 | 'class' => entityClassDetails->getShortName(); ?>::class,
22 | 'placeholder' => 'Choose a entityClassDetails->getShortName(); ?>',
23 | // 'choice_label' => 'name',
24 |
25 | // choose which fields to use in the search
26 | // if not passed, *all* fields are used
27 | // 'searchable_fields' => ['name'],
28 |
29 | // 'security' => 'ROLE_SOMETHING',
30 | ]);
31 | }
32 |
33 | public function getParent(): string
34 | {
35 | return BaseEntityAutocompleteType::class;
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/src/OptionsAwareEntityAutocompleterInterface.php:
--------------------------------------------------------------------------------
1 |
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 | }
21 |
--------------------------------------------------------------------------------
/templates/autocomplete_form_theme.html.twig:
--------------------------------------------------------------------------------
1 | {# EasyAdminAutocomplete form type #}
2 | {% block ux_entity_autocomplete_widget %}
3 | {% if form.autocomplete is defined %}
4 | {{ form_widget(form.autocomplete, {attr: form.autocomplete.vars.attr|merge({required: required})}) }}
5 | {% else %}
6 | {{ form_widget(form) }}
7 | {% endif %}
8 | {% endblock ux_entity_autocomplete_widget %}
9 |
10 | {% block ux_entity_autocomplete_label %}
11 | {% if form.autocomplete is defined %}
12 | {% set id = form.autocomplete.vars.id %}
13 | {% endif %}
14 | {{ block('form_label') }}
15 | {% endblock ux_entity_autocomplete_label %}
16 |
--------------------------------------------------------------------------------
/translations/AutocompleteBundle.ar.php:
--------------------------------------------------------------------------------
1 |
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 | return [
13 | 'Loading more results...' => 'تحميل المزيد من النتائج...',
14 | 'No results found' => 'لم يتم العثور على أي نتائج',
15 | 'No more results' => 'لا توجد نتائج أٌخرى',
16 | // 'Add %placeholder%...' => 'Add %placeholder%...',
17 | ];
18 |
--------------------------------------------------------------------------------
/translations/AutocompleteBundle.bg.php:
--------------------------------------------------------------------------------
1 |
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 | return [
13 | 'Loading more results...' => 'Зареждане на още резултати...',
14 | 'No results found' => 'Няма намерени съвпадения',
15 | 'No more results' => 'Няма повече резултати',
16 | // 'Add %placeholder%...' => 'Add %placeholder%...',
17 | ];
18 |
--------------------------------------------------------------------------------
/translations/AutocompleteBundle.ca.php:
--------------------------------------------------------------------------------
1 |
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 | return [
13 | 'Loading more results...' => 'S\'estan carregant més resultats...',
14 | 'No results found' => 'No s\'han trobat resultats',
15 | 'No more results' => 'No hi ha més resultats',
16 | // 'Add %placeholder%...' => 'Add %placeholder%...',
17 | ];
18 |
--------------------------------------------------------------------------------
/translations/AutocompleteBundle.cs.php:
--------------------------------------------------------------------------------
1 |
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 | return [
13 | 'Loading more results...' => 'Načítání dalších výsledků...',
14 | 'No results found' => 'Nenalezeny žádné položky',
15 | 'No more results' => 'Žádné další výsledky',
16 | 'Add %placeholder%...' => 'Přidat %placeholder%...',
17 | ];
18 |
--------------------------------------------------------------------------------
/translations/AutocompleteBundle.da.php:
--------------------------------------------------------------------------------
1 |
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 | return [
13 | 'Loading more results...' => 'Indlæser flere resultater...',
14 | 'No results found' => 'Ingen resultater fundet',
15 | 'No more results' => 'Ingen flere resultater',
16 | // 'Add %placeholder%...' => 'Add %placeholder%...',
17 | ];
18 |
--------------------------------------------------------------------------------
/translations/AutocompleteBundle.de.php:
--------------------------------------------------------------------------------
1 |
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 | return [
13 | 'Loading more results...' => 'Lade weitere Ergebnisse...',
14 | 'No results found' => 'Keine Übereinstimmungen gefunden',
15 | 'No more results' => 'Keine weiteren Ergebnisse',
16 | 'Add %placeholder%...' => '%placeholder% hinzufügen...',
17 | ];
18 |
--------------------------------------------------------------------------------
/translations/AutocompleteBundle.el.php:
--------------------------------------------------------------------------------
1 |
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 | return [
13 | 'Loading more results...' => 'Φόρτωση περισσότερων αποτελεσμάτων...',
14 | 'No results found' => 'Δεν βρέθηκαν αποτελέσματα',
15 | 'No more results' => 'Δεν υπάρχουν άλλα αποτελέσματα',
16 | // 'Add %placeholder%...' => 'Add %placeholder%...',
17 | ];
18 |
--------------------------------------------------------------------------------
/translations/AutocompleteBundle.en.php:
--------------------------------------------------------------------------------
1 |
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 | return [
13 | 'Loading more results...' => 'Loading more results...',
14 | 'No results found' => 'No results found',
15 | 'No more results' => 'No more results',
16 | 'Add %placeholder%...' => 'Add %placeholder%...',
17 | ];
18 |
--------------------------------------------------------------------------------
/translations/AutocompleteBundle.es.php:
--------------------------------------------------------------------------------
1 |
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 | return [
13 | 'Loading more results...' => 'Cargando más resultados...',
14 | 'No results found' => 'No se han encontrado resultados',
15 | 'No more results' => 'No hay más resultados',
16 | // 'Add %placeholder%...' => 'Add %placeholder%...',
17 | ];
18 |
--------------------------------------------------------------------------------
/translations/AutocompleteBundle.eu.php:
--------------------------------------------------------------------------------
1 |
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 | return [
13 | 'Loading more results...' => 'Emaitza gehiago kargatzen...',
14 | 'No results found' => 'Ez da bat datorrenik aurkitu',
15 | 'No more results' => 'Ez dago emaitza gehiagorik',
16 | // 'Add %placeholder%...' => 'Add %placeholder%...',
17 | ];
18 |
--------------------------------------------------------------------------------
/translations/AutocompleteBundle.fa.php:
--------------------------------------------------------------------------------
1 |
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 | return [
13 | 'Loading more results...' => 'در حال بارگذاری نتایج بیشتر...',
14 | 'No results found' => 'هیچ نتیجهای یافت نشد',
15 | 'No more results' => 'نتیجه دیگری وجود ندارد',
16 | // 'Add %placeholder%...' => 'Add %placeholder%...',
17 | ];
18 |
--------------------------------------------------------------------------------
/translations/AutocompleteBundle.fi.php:
--------------------------------------------------------------------------------
1 |
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 | return [
13 | 'Loading more results...' => 'Ladataan lisää tuloksia...',
14 | 'No results found' => 'Ei tuloksia',
15 | 'No more results' => 'Ei enempää tuloksia',
16 | // 'Add %placeholder%...' => 'Add %placeholder%...',
17 | ];
18 |
--------------------------------------------------------------------------------
/translations/AutocompleteBundle.fr.php:
--------------------------------------------------------------------------------
1 |
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 | return [
13 | 'Loading more results...' => 'Chargement d\'autres résultats...',
14 | 'No results found' => 'Aucun résultat trouvé',
15 | 'No more results' => 'Aucun autre résultat trouvé',
16 | 'Add %placeholder%...' => 'Ajouter %placeholder%...',
17 | ];
18 |
--------------------------------------------------------------------------------
/translations/AutocompleteBundle.gl.php:
--------------------------------------------------------------------------------
1 |
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 | return [
13 | 'Loading more results...' => 'Cargando máis resultados...',
14 | 'No results found' => 'Non se atoparon resultados',
15 | 'No more results' => 'Non hai máis resultados',
16 | // 'Add %placeholder%...' => 'Add %placeholder%...',
17 | ];
18 |
--------------------------------------------------------------------------------
/translations/AutocompleteBundle.hr.php:
--------------------------------------------------------------------------------
1 |
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 | return [
13 | 'Loading more results...' => 'Učitavanje više rezultata...',
14 | 'No results found' => 'Nema rezultata',
15 | 'No more results' => 'Nema više rezultata',
16 | // 'Add %placeholder%...' => 'Add %placeholder%...',
17 | ];
18 |
--------------------------------------------------------------------------------
/translations/AutocompleteBundle.hu.php:
--------------------------------------------------------------------------------
1 |
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 | return [
13 | 'Loading more results...' => 'További találatok betöltése...',
14 | 'No results found' => 'Nincs találat',
15 | 'No more results' => 'Nincs több találat',
16 | 'Add %placeholder%...' => '"%placeholder%" hozzáadása',
17 | ];
18 |
--------------------------------------------------------------------------------
/translations/AutocompleteBundle.id.php:
--------------------------------------------------------------------------------
1 |
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 | return [
13 | 'Loading more results...' => 'Memuat hasil lebih banyak...',
14 | 'No results found' => 'Tidak ada hasil yang ditemukan',
15 | 'No more results' => 'Tidak ada hasil lagi',
16 | // 'Add %placeholder%...' => 'Add %placeholder%...',
17 | ];
18 |
--------------------------------------------------------------------------------
/translations/AutocompleteBundle.it.php:
--------------------------------------------------------------------------------
1 |
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 | return [
13 | 'Loading more results...' => 'Caricamento di altri risultati...',
14 | 'No results found' => 'Nessun risultato trovato',
15 | 'No more results' => 'Non ci sono altri risultati',
16 | // 'Add %placeholder%...' => 'Add %placeholder%...',
17 | ];
18 |
--------------------------------------------------------------------------------
/translations/AutocompleteBundle.lb.php:
--------------------------------------------------------------------------------
1 |
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 | return [
13 | 'Loading more results...' => 'Lueden méi Resultater...',
14 | 'No results found' => 'Keng Resultater fonnt',
15 | 'No more results' => 'Keng weider Resultater',
16 | // 'Add %placeholder%...' => 'Add %placeholder%...',
17 | ];
18 |
--------------------------------------------------------------------------------
/translations/AutocompleteBundle.lt.php:
--------------------------------------------------------------------------------
1 |
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 | return [
13 | 'Loading more results...' => 'Kraunama daugiau rezultatų...',
14 | 'No results found' => 'Atitikmenų nerasta',
15 | 'No more results' => 'Daugiau rezultatų nėra',
16 | // 'Add %placeholder%...' => 'Add %placeholder%...',
17 | ];
18 |
--------------------------------------------------------------------------------
/translations/AutocompleteBundle.nl.php:
--------------------------------------------------------------------------------
1 |
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 | return [
13 | 'Loading more results...' => 'Meer resultaten aan het laden...',
14 | 'No results found' => 'Geen resultaten gevonden…',
15 | 'No more results' => 'Niet meer resultaten gevonden…',
16 | 'Add %placeholder%...' => 'Voeg %placeholder% toe...',
17 | ];
18 |
--------------------------------------------------------------------------------
/translations/AutocompleteBundle.pl.php:
--------------------------------------------------------------------------------
1 |
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 | return [
13 | 'Loading more results...' => 'Wczytywanie więcej wyników...',
14 | 'No results found' => 'Brak wyników',
15 | 'No more results' => 'Brak więcej wyników',
16 | 'Add %placeholder%...' => 'Dodaj %placeholder%...',
17 | ];
18 |
--------------------------------------------------------------------------------
/translations/AutocompleteBundle.pt.php:
--------------------------------------------------------------------------------
1 |
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 | return [
13 | 'Loading more results...' => 'Carregando mais resultados...',
14 | 'No results found' => 'Sem resultados',
15 | 'No more results' => 'Não há mais resultados',
16 | // 'Add %placeholder%...' => 'Add %placeholder%...',
17 | ];
18 |
--------------------------------------------------------------------------------
/translations/AutocompleteBundle.pt_BR.php:
--------------------------------------------------------------------------------
1 |
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 | return [
13 | 'Loading more results...' => 'Carregando mais resultados...',
14 | 'No results found' => 'Nenhum resultado encontrado',
15 | 'No more results' => 'Não há mais resultados',
16 | // 'Add %placeholder%...' => 'Add %placeholder%...',
17 | ];
18 |
--------------------------------------------------------------------------------
/translations/AutocompleteBundle.ro.php:
--------------------------------------------------------------------------------
1 |
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 | return [
13 | 'Loading more results...' => 'Se încarcă mai multe rezultate...',
14 | 'No results found' => 'Nu au fost găsite rezultate',
15 | 'No more results' => 'Nu mai sunt alte rezultate',
16 | // 'Add %placeholder%...' => 'Add %placeholder%...',
17 | ];
18 |
--------------------------------------------------------------------------------
/translations/AutocompleteBundle.ru.php:
--------------------------------------------------------------------------------
1 |
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 | return [
13 | 'Loading more results...' => 'Загрузка дополнительных результатов...',
14 | 'No results found' => 'Совпадений не найдено',
15 | 'No more results' => 'Больше результатов нет',
16 | // 'Add %placeholder%...' => 'Add %placeholder%...',
17 | ];
18 |
--------------------------------------------------------------------------------
/translations/AutocompleteBundle.sk.php:
--------------------------------------------------------------------------------
1 |
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 | return [
13 | 'Loading more results...' => 'Načítavajú sa ďalšie výsledky...',
14 | 'No results found' => 'Neboli nájdené žiadne výsledky',
15 | 'No more results' => 'Žiadne ďalšie výsledky',
16 | 'Add %placeholder%...' => 'Pridať %placeholder%...',
17 | ];
18 |
--------------------------------------------------------------------------------
/translations/AutocompleteBundle.sl.php:
--------------------------------------------------------------------------------
1 |
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 | return [
13 | 'Loading more results...' => 'Nalaganje več rezultatov...',
14 | 'No results found' => 'Ni zadetkov',
15 | 'No more results' => 'Ni več rezultatov',
16 | // 'Add %placeholder%...' => 'Add %placeholder%...',
17 | ];
18 |
--------------------------------------------------------------------------------
/translations/AutocompleteBundle.sr_RS.php:
--------------------------------------------------------------------------------
1 |
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 | return [
13 | 'Loading more results...' => 'Учитавање још резултата...',
14 | 'No results found' => 'Nema rezultata',
15 | 'No more results' => 'Nema više rezultata',
16 | // 'Add %placeholder%...' => 'Add %placeholder%...',
17 | ];
18 |
--------------------------------------------------------------------------------
/translations/AutocompleteBundle.sv.php:
--------------------------------------------------------------------------------
1 |
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 | return [
13 | 'Loading more results...' => 'Laddar fler resultat...',
14 | 'No results found' => 'Inga träffar',
15 | 'No more results' => 'Inga fler resultat',
16 | // 'Add %placeholder%...' => 'Add %placeholder%...',
17 | ];
18 |
--------------------------------------------------------------------------------
/translations/AutocompleteBundle.tr.php:
--------------------------------------------------------------------------------
1 |
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 | return [
13 | 'Loading more results...' => 'Daha fazla sonuç yükleniyor...',
14 | 'No results found' => 'Sonuç bulunamadı',
15 | 'No more results' => 'Başka sonuç yok',
16 | 'Add %placeholder%...' => '%placeholder% Ekle...',
17 | ];
18 |
--------------------------------------------------------------------------------
/translations/AutocompleteBundle.uk.php:
--------------------------------------------------------------------------------
1 |
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 | return [
13 | 'Loading more results...' => 'Завантаження додаткових результатів...',
14 | 'No results found' => 'Нічого не знайдено',
15 | 'No more results' => 'Більше результатів немає',
16 | // 'Add %placeholder%...' => 'Add %placeholder%...',
17 | ];
18 |
--------------------------------------------------------------------------------
/translations/AutocompleteBundle.zh_CN.php:
--------------------------------------------------------------------------------
1 |
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 | return [
13 | 'Loading more results...' => '加载更多结果...',
14 | 'No results found' => '未找到结果',
15 | 'No more results' => '没有更多结果',
16 | // 'Add %placeholder%...' => 'Add %placeholder%...',
17 | ];
18 |
--------------------------------------------------------------------------------