.');
+ }
+ return this.element;
+ }
+}
+_instances = new WeakSet(), _getCommonConfig = function _getCommonConfig() {
+ const plugins = {};
+ const isMultiple = !this.selectElement || this.selectElement.multiple;
+ if (!this.formElement.disabled && !isMultiple) {
+ plugins.clear_button = { title: '' };
+ }
+ if (isMultiple) {
+ plugins.remove_button = { title: '' };
+ }
+ if (this.urlValue) {
+ plugins.virtual_scroll = {};
+ }
+ const config = {
+ render: {
+ no_results: () => {
+ return `${this.noResultsFoundTextValue}
`;
+ },
+ },
+ plugins: plugins,
+ onItemAdd: () => {
+ this.tomSelect.setTextboxValue('');
+ },
+ closeAfterSelect: true,
+ };
+ if (!this.selectElement && !this.urlValue) {
+ config.shouldLoad = () => false;
+ }
+ return __classPrivateFieldGet(this, _instances, "m", _mergeObjects).call(this, config, this.tomSelectOptionsValue);
+}, _createAutocomplete = function _createAutocomplete() {
+ const config = __classPrivateFieldGet(this, _instances, "m", _mergeObjects).call(this, __classPrivateFieldGet(this, _instances, "m", _getCommonConfig).call(this), {
+ maxOptions: this.selectElement ? this.selectElement.options.length : 50,
+ });
+ return __classPrivateFieldGet(this, _instances, "m", _createTomSelect).call(this, config);
+}, _createAutocompleteWithHtmlContents = function _createAutocompleteWithHtmlContents() {
+ const config = __classPrivateFieldGet(this, _instances, "m", _mergeObjects).call(this, __classPrivateFieldGet(this, _instances, "m", _getCommonConfig).call(this), {
+ maxOptions: this.selectElement ? this.selectElement.options.length : 50,
+ score: (search) => {
+ const scoringFunction = this.tomSelect.getScoreFunction(search);
+ return (item) => {
+ return scoringFunction(Object.assign(Object.assign({}, item), { text: __classPrivateFieldGet(this, _instances, "m", _stripTags).call(this, item.text) }));
+ };
+ },
+ render: {
+ item: function (item) {
+ return `${item.text}
`;
+ },
+ option: function (item) {
+ return `${item.text}
`;
+ }
+ },
+ });
+ return __classPrivateFieldGet(this, _instances, "m", _createTomSelect).call(this, config);
+}, _createAutocompleteWithRemoteData = function _createAutocompleteWithRemoteData(autocompleteEndpointUrl) {
+ const config = __classPrivateFieldGet(this, _instances, "m", _mergeObjects).call(this, __classPrivateFieldGet(this, _instances, "m", _getCommonConfig).call(this), {
+ firstUrl: (query) => {
+ const separator = autocompleteEndpointUrl.includes('?') ? '&' : '?';
+ return `${autocompleteEndpointUrl}${separator}query=${encodeURIComponent(query)}`;
+ },
+ load: function (query, callback) {
+ const url = this.getUrl(query);
+ fetch(url)
+ .then(response => response.json())
+ .then(json => { this.setNextUrl(query, json.next_page); callback(json.results); })
+ .catch(() => callback());
+ },
+ score: function (search) {
+ return function (item) {
+ return 1;
+ };
+ },
+ render: {
+ option: function (item) {
+ return `${item.text}
`;
+ },
+ item: function (item) {
+ return `${item.text}
`;
+ },
+ no_more_results: () => {
+ return `${this.noMoreResultsTextValue}
`;
+ },
+ no_results: () => {
+ return `${this.noResultsFoundTextValue}
`;
+ },
+ },
+ preload: 'focus',
+ });
+ return __classPrivateFieldGet(this, _instances, "m", _createTomSelect).call(this, config);
+}, _stripTags = function _stripTags(string) {
+ return string.replace(/(<([^>]+)>)/gi, '');
+}, _mergeObjects = function _mergeObjects(object1, object2) {
+ return Object.assign(Object.assign({}, object1), object2);
+}, _createTomSelect = function _createTomSelect(options) {
+ __classPrivateFieldGet(this, _instances, "m", _dispatchEvent).call(this, 'autocomplete:pre-connect', { options });
+ const tomSelect = new TomSelect(this.formElement, options);
+ __classPrivateFieldGet(this, _instances, "m", _dispatchEvent).call(this, 'autocomplete:connect', { tomSelect, options });
+ return tomSelect;
+}, _dispatchEvent = function _dispatchEvent(name, payload) {
+ this.element.dispatchEvent(new CustomEvent(name, { detail: payload, bubbles: true }));
+};
+default_1.values = {
+ url: String,
+ optionsAsHtml: Boolean,
+ noResultsFoundText: String,
+ noMoreResultsText: String,
+ tomSelectOptions: Object,
+};
+
+export { default_1 as default };
diff --git a/src/Autocomplete/assets/jest.config.js b/src/Autocomplete/assets/jest.config.js
new file mode 100644
index 00000000000..eff5daf8ad7
--- /dev/null
+++ b/src/Autocomplete/assets/jest.config.js
@@ -0,0 +1,5 @@
+const config = require('../../../jest.config.js');
+
+config.setupFilesAfterEnv.push('./test/setup.js');
+
+module.exports = config;
diff --git a/src/Autocomplete/assets/package.json b/src/Autocomplete/assets/package.json
new file mode 100644
index 00000000000..d2e7d5e2d5a
--- /dev/null
+++ b/src/Autocomplete/assets/package.json
@@ -0,0 +1,30 @@
+{
+ "name": "@symfony/ux-autocomplete",
+ "description": "JavaScript-powered autocompletion functionality for forms.",
+ "main": "dist/controller.js",
+ "module": "dist/controller.js",
+ "version": "1.0.0",
+ "license": "MIT",
+ "symfony": {
+ "controllers": {
+ "autocomplete": {
+ "main": "dist/controller.js",
+ "webpackMode": "eager",
+ "fetch": "eager",
+ "enabled": true,
+ "autoimport": {
+ "tom-select/dist/css/tom-select.default.css": true
+ }
+ }
+ }
+ },
+ "peerDependencies": {
+ "@hotwired/stimulus": "^3.0.0",
+ "tom-select": "^2.0.1"
+ },
+ "devDependencies": {
+ "@hotwired/stimulus": "^3.0.0",
+ "fetch-mock-jest": "^1.5.1",
+ "tom-select": "^2.0.1"
+ }
+}
diff --git a/src/Autocomplete/assets/src/controller.ts b/src/Autocomplete/assets/src/controller.ts
new file mode 100644
index 00000000000..1c1b8aeea26
--- /dev/null
+++ b/src/Autocomplete/assets/src/controller.ts
@@ -0,0 +1,197 @@
+import { Controller } from '@hotwired/stimulus';
+import TomSelect from 'tom-select';
+import { TomSettings } from 'tom-select/dist/types/types';
+
+export default class extends Controller {
+ static values = {
+ url: String,
+ optionsAsHtml: Boolean,
+ noResultsFoundText: String,
+ noMoreResultsText: String,
+ tomSelectOptions: Object,
+ }
+
+ readonly urlValue: string;
+ readonly optionsAsHtmlValue: boolean;
+ readonly noMoreResultsTextValue: string;
+ readonly noResultsFoundTextValue: string;
+ readonly tomSelectOptionsValue: object;
+ tomSelect: TomSelect;
+
+ connect() {
+ // this avoids initializing the same field twice (TomSelect shows an error otherwise)
+ if (this.tomSelect) {
+ return;
+ }
+
+ if (this.urlValue) {
+ this.tomSelect = this.#createAutocompleteWithRemoteData(this.urlValue);
+
+ return;
+ }
+
+ if (this.optionsAsHtmlValue) {
+ this.tomSelect = this.#createAutocompleteWithHtmlContents();
+
+ return;
+ }
+
+ this.tomSelect = this.#createAutocomplete();
+ }
+
+ #getCommonConfig(): Partial {
+ const plugins: any = {}
+
+ // multiple values excepted if this is NOT A select (i.e. input) or a multiple select
+ const isMultiple = !this.selectElement || this.selectElement.multiple;
+ if (!this.formElement.disabled && !isMultiple) {
+ plugins.clear_button = { title: '' };
+ }
+
+ if (isMultiple) {
+ plugins.remove_button = { title: '' };
+ }
+
+ if (this.urlValue) {
+ plugins.virtual_scroll = {};
+ }
+
+ const config: Partial = {
+ render: {
+ no_results: () => {
+ return `${this.noResultsFoundTextValue}
`;
+ },
+ },
+ plugins: plugins,
+ // clear the text input after selecting a value
+ onItemAdd: () => {
+ this.tomSelect.setTextboxValue('');
+ },
+ closeAfterSelect: true,
+ };
+
+ // for non-autocompleting input elements, avoid the "No results" message that always shows
+ if (!this.selectElement && !this.urlValue) {
+ config.shouldLoad = () => false;
+ }
+
+ return this.#mergeObjects(config, this.tomSelectOptionsValue);
+ }
+
+ #createAutocomplete(): TomSelect {
+ const config = this.#mergeObjects(this.#getCommonConfig(), {
+ maxOptions: this.selectElement ? this.selectElement.options.length : 50,
+ });
+
+ return this.#createTomSelect(config);
+ }
+
+ #createAutocompleteWithHtmlContents(): TomSelect {
+ const config = this.#mergeObjects(this.#getCommonConfig(), {
+ maxOptions: this.selectElement ? this.selectElement.options.length : 50,
+ score: (search: string) => {
+ const scoringFunction = this.tomSelect.getScoreFunction(search);
+ return (item: any) => {
+ // strip HTML tags from each option's searchable text
+ return scoringFunction({ ...item, text: this.#stripTags(item.text) });
+ };
+ },
+ render: {
+ item: function(item: any) {
+ return `${item.text}
`;
+ },
+ option: function(item: any) {
+ return `${item.text}
`;
+ }
+ },
+ });
+
+ return this.#createTomSelect(config);
+ }
+
+ #createAutocompleteWithRemoteData(autocompleteEndpointUrl: string): TomSelect {
+ const config: Partial = this.#mergeObjects(this.#getCommonConfig(), {
+ firstUrl: (query: string) => {
+ const separator = autocompleteEndpointUrl.includes('?') ? '&' : '?';
+
+ return `${autocompleteEndpointUrl}${separator}query=${encodeURIComponent(query)}`;
+ },
+ // VERY IMPORTANT: use 'function (query, callback) { ... }' instead of the
+ // '(query, callback) => { ... }' syntax because, otherwise,
+ // the 'this.XXX' calls inside of this method fail
+ load: function (query: string, callback: (results?: any) => void) {
+ const url = this.getUrl(query);
+ fetch(url)
+ .then(response => response.json())
+ // important: next_url must be set before invoking callback()
+ .then(json => { this.setNextUrl(query, json.next_page); callback(json.results) })
+ .catch(() => callback());
+ },
+ // avoid extra filtering after results are returned
+ score: function(search: string) {
+ return function(item: any) {
+ return 1;
+ };
+ },
+ render: {
+ option: function(item: any) {
+ return `${item.text}
`;
+ },
+ item: function(item: any) {
+ return `${item.text}
`;
+ },
+ no_more_results: (): string => {
+ return `${this.noMoreResultsTextValue}
`;
+ },
+ no_results: (): string => {
+ return `${this.noResultsFoundTextValue}
`;
+ },
+ },
+ preload: 'focus',
+ });
+
+ return this.#createTomSelect(config);
+ }
+
+ #stripTags(string: string): string {
+ return string.replace(/(<([^>]+)>)/gi, '');
+ }
+
+ #mergeObjects(object1: any, object2: any): any {
+ return { ...object1, ...object2 };
+ }
+
+ /**
+ * Returns the element, but only if it's a select element.
+ */
+ get selectElement(): HTMLSelectElement|null {
+ if (!(this.element instanceof HTMLSelectElement)) {
+ return null;
+ }
+
+ return this.element;
+ }
+
+ /**
+ * Getter to help typing.
+ */
+ get formElement(): HTMLInputElement|HTMLSelectElement {
+ if (!(this.element instanceof HTMLInputElement) && !(this.element instanceof HTMLSelectElement)) {
+ throw new Error('Autocomplete Stimulus controller can only be used no an or .');
+ }
+
+ return this.element;
+ }
+
+ #createTomSelect(options: Partial): TomSelect {
+ this.#dispatchEvent('autocomplete:pre-connect', { options });
+ const tomSelect = new TomSelect(this.formElement, options);
+ this.#dispatchEvent('autocomplete:connect', { tomSelect, options });
+
+ return tomSelect;
+ }
+
+ #dispatchEvent(name: string, payload: any): void {
+ this.element.dispatchEvent(new CustomEvent(name, { detail: payload, bubbles: true }));
+ }
+}
diff --git a/src/Autocomplete/assets/test/controller.test.ts b/src/Autocomplete/assets/test/controller.test.ts
new file mode 100644
index 00000000000..4627df6f6d9
--- /dev/null
+++ b/src/Autocomplete/assets/test/controller.test.ts
@@ -0,0 +1,140 @@
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+'use strict';
+
+import { Application, Controller } from '@hotwired/stimulus';
+import { getByTestId, waitFor, getAllByLabelText } from '@testing-library/dom';
+import { clearDOM, mountDOM } from '@symfony/stimulus-testing';
+import AutocompleteController from '../src/controller';
+import fetchMock from 'fetch-mock-jest';
+import userEvent from '@testing-library/user-event';
+
+// Controller used to check the actual controller was properly booted
+class CheckController extends Controller {
+ connect() {
+ this.element.addEventListener('autocomplete:pre-connect', () => {
+ this.element.classList.add('pre-connected');
+ });
+
+ this.element.addEventListener('autocomplete:connect', (event: any) => {
+ this.element.classList.add('connected');
+ this.element.tomSelect = event.detail.tomSelect;
+ });
+ }
+}
+
+const startStimulus = (): Application => {
+ const application = Application.start();
+ application.register('check', CheckController);
+ application.register('autocomplete', AutocompleteController);
+
+ return application;
+};
+
+describe('AutocompleteController', () => {
+ let application: Application;
+
+ afterEach(() => {
+ clearDOM();
+ application.stop();
+
+ if (!fetchMock.done()) {
+ throw new Error('Mocked requests did not match');
+ }
+ fetchMock.reset();
+ });
+
+ it('connect without options', async () => {
+ const container = mountDOM(`
+
+ `);
+
+ expect(getByTestId(container, 'main-element')).not.toHaveClass('pre-connected');
+ expect(getByTestId(container, 'main-element')).not.toHaveClass('connected');
+
+ application = startStimulus();
+
+ await waitFor(() => {
+ expect(getByTestId(container, 'main-element')).toHaveClass('pre-connected');
+ expect(getByTestId(container, 'main-element')).toHaveClass('connected');
+ });
+
+ const tomSelect = getByTestId(container, 'main-element').tomSelect;
+ expect(tomSelect.input).toBe(getByTestId(container, 'main-element'));
+ });
+
+ it('connect with ajax URL', async () => {
+ const container = mountDOM(`
+ Items
+
+ `);
+
+ application = startStimulus();
+
+ await waitFor(() => {
+ expect(getByTestId(container, 'main-element')).toHaveClass('connected');
+ });
+
+ // initial Ajax request on focus
+ fetchMock.mock(
+ '/path/to/autocomplete?query=',
+ JSON.stringify({
+ results: [
+ {
+ value: 3,
+ text: 'salad'
+ },
+ ],
+ }),
+ );
+
+ fetchMock.mock(
+ '/path/to/autocomplete?query=foo',
+ JSON.stringify({
+ results: [
+ {
+ value: 1,
+ text: 'pizza'
+ },
+ {
+ value: 2,
+ text: 'popcorn'
+ },
+ ],
+ }),
+ );
+
+ const tomSelect = getByTestId(container, 'main-element').tomSelect;
+ const controlInput = tomSelect.control_input;
+
+ // wait for the initial Ajax request to finish
+ userEvent.click(controlInput);
+ await waitFor(() => {
+ expect(container.querySelectorAll('.option[data-selectable]')).toHaveLength(1);
+ });
+
+ // typing was not properly triggering, for some reason
+ //userEvent.type(controlInput, 'foo');
+ controlInput.value = 'foo';
+ controlInput.dispatchEvent(new Event('input'));
+
+ await waitFor(() => {
+ expect(container.querySelectorAll('.option[data-selectable]')).toHaveLength(2);
+ });
+ });
+});
diff --git a/src/Autocomplete/assets/test/setup.js b/src/Autocomplete/assets/test/setup.js
new file mode 100644
index 00000000000..af928c33cf4
--- /dev/null
+++ b/src/Autocomplete/assets/test/setup.js
@@ -0,0 +1,12 @@
+/*
+ * This file is part of the Symfony Live Component package.
+ *
+ * (c) Fabien Potencier
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+// adds the missing "fetch" function - fetch-mock-jest will replace this
+// eslint-disable-next-line
+global.fetch = require('node-fetch');
diff --git a/src/Autocomplete/composer.json b/src/Autocomplete/composer.json
new file mode 100644
index 00000000000..df4032610c8
--- /dev/null
+++ b/src/Autocomplete/composer.json
@@ -0,0 +1,58 @@
+{
+ "name": "symfony/ux-autocomplete",
+ "type": "symfony-bundle",
+ "description": "JavaScript Autocomplete functionality for Symfony",
+ "keywords": [
+ "symfony-ux"
+ ],
+ "homepage": "https://symfony.com",
+ "license": "MIT",
+ "authors": [
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "autoload": {
+ "psr-4": {
+ "Symfony\\UX\\Autocomplete\\": "src/"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "Symfony\\UX\\Autocomplete\\Tests\\": "tests/"
+ }
+ },
+ "require": {
+ "php": ">=8.0",
+ "symfony/dependency-injection": "^5.4|^6.0",
+ "symfony/http-foundation": "^5.4|^6.0",
+ "symfony/http-kernel": "^5.4|^6.0",
+ "symfony/string": "^5.4|^6.0"
+ },
+ "require-dev": {
+ "doctrine/doctrine-bundle": "^2.0",
+ "doctrine/orm": "^2.7",
+ "mtdowling/jmespath.php": "2.6.x-dev",
+ "symfony/form": "^5.4|^6.0",
+ "symfony/framework-bundle": "^5.4|^6.0",
+ "symfony/maker-bundle": "^1.40",
+ "symfony/phpunit-bridge": "^5.4|^6.0",
+ "symfony/process": "^5.4|^6.0",
+ "symfony/security-bundle": "^5.4|^6.0",
+ "symfony/security-csrf": "^5.4|^6.0",
+ "symfony/twig-bundle": "^5.4|^6.0",
+ "zenstruck/browser": "^1.1",
+ "zenstruck/foundry": "^1.19"
+ },
+ "config": {
+ "sort-packages": true
+ },
+ "extra": {
+ "thanks": {
+ "name": "symfony/ux",
+ "url": "https://github.com/symfony/ux"
+ }
+ },
+ "minimum-stability": "dev"
+}
diff --git a/src/Autocomplete/phpunit.xml.dist b/src/Autocomplete/phpunit.xml.dist
new file mode 100644
index 00000000000..cf4b7344ac7
--- /dev/null
+++ b/src/Autocomplete/phpunit.xml.dist
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ./tests/
+
+
+
+
+
+ ./src
+
+
+
+
+
+
+
diff --git a/src/Autocomplete/src/AutocompleteBundle.php b/src/Autocomplete/src/AutocompleteBundle.php
new file mode 100644
index 00000000000..e278ab90970
--- /dev/null
+++ b/src/Autocomplete/src/AutocompleteBundle.php
@@ -0,0 +1,29 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\Autocomplete;
+
+use Symfony\Component\DependencyInjection\ContainerBuilder;
+use Symfony\Component\HttpKernel\Bundle\Bundle;
+use Symfony\UX\Autocomplete\DependencyInjection\AutocompleteFormTypePass;
+
+/**
+ * @author Ryan Weaver
+ *
+ * @experimental
+ */
+final class AutocompleteBundle extends Bundle
+{
+ public function build(ContainerBuilder $container)
+ {
+ $container->addCompilerPass(new AutocompleteFormTypePass());
+ }
+}
diff --git a/src/Autocomplete/src/AutocompleteResultsExecutor.php b/src/Autocomplete/src/AutocompleteResultsExecutor.php
new file mode 100644
index 00000000000..264b79df409
--- /dev/null
+++ b/src/Autocomplete/src/AutocompleteResultsExecutor.php
@@ -0,0 +1,60 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\Autocomplete;
+
+use Symfony\Component\Security\Core\Exception\AccessDeniedException;
+use Symfony\Component\Security\Core\Security;
+use Symfony\UX\Autocomplete\Doctrine\DoctrineRegistryWrapper;
+use Symfony\UX\Autocomplete\Doctrine\EntitySearchUtil;
+
+/**
+ * @author Ryan Weaver
+ *
+ * @experimental
+ */
+final class AutocompleteResultsExecutor
+{
+ public function __construct(
+ private EntitySearchUtil $entitySearchUtil,
+ private DoctrineRegistryWrapper $managerRegistry,
+ private ?Security $security = null
+ ) {
+ }
+
+ public function fetchResults(EntityAutocompleterInterface $autocompleter, string $query): array
+ {
+ if ($this->security && !$autocompleter->isGranted($this->security)) {
+ throw new AccessDeniedException('Access denied from autocompleter class.');
+ }
+
+ $queryBuilder = $autocompleter->getQueryBuilder($this->managerRegistry->getRepository($autocompleter->getEntityClass()));
+ $searchableProperties = $autocompleter->getSearchableFields();
+ $this->entitySearchUtil->addSearchClause($queryBuilder, $query, $autocompleter->getEntityClass(), $searchableProperties);
+
+ // if no max is set, set one
+ if (!$queryBuilder->getMaxResults()) {
+ $queryBuilder->setMaxResults(10);
+ }
+
+ $entities = $queryBuilder->getQuery()->execute();
+
+ $results = [];
+ foreach ($entities as $entity) {
+ $results[] = [
+ 'value' => $autocompleter->getValue($entity),
+ 'text' => $autocompleter->getLabel($entity),
+ ];
+ }
+
+ return $results;
+ }
+}
diff --git a/src/Autocomplete/src/AutocompleterRegistry.php b/src/Autocomplete/src/AutocompleterRegistry.php
new file mode 100644
index 00000000000..732ec30edcf
--- /dev/null
+++ b/src/Autocomplete/src/AutocompleterRegistry.php
@@ -0,0 +1,37 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\Autocomplete;
+
+use Symfony\Component\DependencyInjection\ServiceLocator;
+
+/**
+ * @author Ryan Weaver
+ *
+ * @experimental
+ */
+final class AutocompleterRegistry
+{
+ public function __construct(
+ private ServiceLocator $autocompletersLocator
+ ) {
+ }
+
+ public function getAutocompleter(string $alias): ?EntityAutocompleterInterface
+ {
+ return $this->autocompletersLocator->has($alias) ? $this->autocompletersLocator->get($alias) : null;
+ }
+
+ public function getAutocompleterNames(): array
+ {
+ return array_keys($this->autocompletersLocator->getProvidedServices());
+ }
+}
diff --git a/src/Autocomplete/src/Controller/EntityAutocompleteController.php b/src/Autocomplete/src/Controller/EntityAutocompleteController.php
new file mode 100644
index 00000000000..91e6e014553
--- /dev/null
+++ b/src/Autocomplete/src/Controller/EntityAutocompleteController.php
@@ -0,0 +1,47 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\Autocomplete\Controller;
+
+use Symfony\Component\HttpFoundation\JsonResponse;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
+use Symfony\UX\Autocomplete\AutocompleteResultsExecutor;
+use Symfony\UX\Autocomplete\AutocompleterRegistry;
+
+/**
+ * @author Ryan Weaver
+ *
+ * @experimental
+ */
+final class EntityAutocompleteController
+{
+ public function __construct(
+ private AutocompleterRegistry $autocompleteFieldRegistry,
+ private AutocompleteResultsExecutor $autocompleteResultsExecutor
+ ) {
+ }
+
+ public function __invoke(string $alias, Request $request): Response
+ {
+ $autocompleter = $this->autocompleteFieldRegistry->getAutocompleter($alias);
+ if (!$autocompleter) {
+ throw new NotFoundHttpException(sprintf('No autocompleter found for "%s". Available autocompleters are: (%s)', $alias, implode(', ', $this->autocompleteFieldRegistry->getAutocompleterNames())));
+ }
+
+ $results = $this->autocompleteResultsExecutor->fetchResults($autocompleter, $request->query->get('query', ''));
+
+ return new JsonResponse([
+ 'results' => $results,
+ ]);
+ }
+}
diff --git a/src/Autocomplete/src/DependencyInjection/AutocompleteExtension.php b/src/Autocomplete/src/DependencyInjection/AutocompleteExtension.php
new file mode 100644
index 00000000000..f8e0d82ee9e
--- /dev/null
+++ b/src/Autocomplete/src/DependencyInjection/AutocompleteExtension.php
@@ -0,0 +1,148 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\Autocomplete\DependencyInjection;
+
+use Symfony\Component\DependencyInjection\ContainerBuilder;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+use Symfony\Component\DependencyInjection\Definition;
+use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface;
+use Symfony\Component\DependencyInjection\Reference;
+use Symfony\Component\Form\Form;
+use Symfony\Component\HttpKernel\DependencyInjection\Extension;
+use Symfony\UX\Autocomplete\AutocompleteResultsExecutor;
+use Symfony\UX\Autocomplete\AutocompleterRegistry;
+use Symfony\UX\Autocomplete\Controller\EntityAutocompleteController;
+use Symfony\UX\Autocomplete\Doctrine\DoctrineRegistryWrapper;
+use Symfony\UX\Autocomplete\Doctrine\EntityMetadataFactory;
+use Symfony\UX\Autocomplete\Doctrine\EntitySearchUtil;
+use Symfony\UX\Autocomplete\Form\AsEntityAutocompleteField;
+use Symfony\UX\Autocomplete\Form\AutocompleteChoiceTypeExtension;
+use Symfony\UX\Autocomplete\Form\ParentEntityAutocompleteType;
+use Symfony\UX\Autocomplete\Form\WrappedEntityTypeAutocompleter;
+use Symfony\UX\Autocomplete\Maker\MakeAutocompleteField;
+use function Symfony\Component\DependencyInjection\Loader\Configurator\abstract_arg;
+
+/**
+ * @author Ryan Weaver
+ *
+ * @experimental
+ */
+final class AutocompleteExtension extends Extension implements PrependExtensionInterface
+{
+ public function prepend(ContainerBuilder $container)
+ {
+ $bundles = $container->getParameter('kernel.bundles');
+
+ if (!isset($bundles['TwigBundle'])) {
+ return;
+ }
+
+ $container->prependExtensionConfig('twig', [
+ 'form_themes' => ['@Autocomplete/autocomplete_form_theme.html.twig'],
+ ]);
+ }
+
+ public function load(array $configs, ContainerBuilder $container)
+ {
+ $this->registerBasicServices($container);
+ if (ContainerBuilder::willBeAvailable('symfony/form', Form::class, ['symfony/framework-bundle'])) {
+ $this->registerFormServices($container);
+ }
+ }
+
+ private function registerBasicServices(ContainerBuilder $container): void
+ {
+ $container->registerAttributeForAutoconfiguration(AsEntityAutocompleteField::class, function (Definition $definition) {
+ $definition->addTag(AutocompleteFormTypePass::ENTITY_AUTOCOMPLETE_FIELD_TAG);
+ });
+
+ $container
+ ->register('ux.autocomplete.autocompleter_registry', AutocompleterRegistry::class)
+ ->setArguments([
+ abstract_arg('autocompleter service locator'),
+ ]);
+
+ $container
+ ->register('ux.autocomplete.doctrine_registry_wrapper', DoctrineRegistryWrapper::class)
+ ->setArguments([
+ new Reference('doctrine', ContainerInterface::IGNORE_ON_INVALID_REFERENCE),
+ ])
+ ;
+
+ $container
+ ->register('ux.autocomplete.results_executor', AutocompleteResultsExecutor::class)
+ ->setArguments([
+ new Reference('ux.autocomplete.entity_search_util'),
+ new Reference('ux.autocomplete.doctrine_registry_wrapper'),
+ new Reference('security.helper', ContainerInterface::NULL_ON_INVALID_REFERENCE),
+ ])
+ ;
+
+ $container
+ ->register('ux.autocomplete.entity_search_util', EntitySearchUtil::class)
+ ->setArguments([
+ new Reference('ux.autocomplete.entity_metadata_factory'),
+ ])
+ ;
+
+ $container
+ ->register('ux.autocomplete.entity_metadata_factory', EntityMetadataFactory::class)
+ ->setArguments([
+ new Reference('ux.autocomplete.doctrine_registry_wrapper'),
+ ])
+ ;
+
+ $container
+ ->register('ux.autocomplete.entity_autocomplete_controller', EntityAutocompleteController::class)
+ ->setArguments([
+ new Reference('ux.autocomplete.autocompleter_registry'),
+ new Reference('ux.autocomplete.results_executor'),
+ ])
+ ->addTag('controller.service_arguments')
+ ;
+
+ $container
+ ->register('ux.autocomplete.make_autocomplete_field', MakeAutocompleteField::class)
+ ->setArguments([
+ new Reference('maker.doctrine_helper', ContainerInterface::IGNORE_ON_INVALID_REFERENCE),
+ ])
+ ->addTag('maker.command')
+ ;
+ }
+
+ private function registerFormServices(ContainerBuilder $container): void
+ {
+ $container
+ ->register('ux.autocomplete.entity_type', ParentEntityAutocompleteType::class)
+ ->setArguments([
+ new Reference('router'),
+ ])
+ ->addTag('form.type');
+
+ $container
+ ->register('ux.autocomplete.choice_type_extension', AutocompleteChoiceTypeExtension::class)
+ ->setArguments([
+ new Reference('translator', ContainerInterface::IGNORE_ON_INVALID_REFERENCE),
+ ])
+ ->addTag('form.type_extension');
+
+ $container
+ ->register('ux.autocomplete.wrapped_entity_type_autocompleter', WrappedEntityTypeAutocompleter::class)
+ ->setAbstract(true)
+ ->setArguments([
+ abstract_arg('form type string'),
+ new Reference('form.factory'),
+ new Reference('ux.autocomplete.entity_metadata_factory'),
+ new Reference('property_accessor'),
+ ]);
+ }
+}
diff --git a/src/Autocomplete/src/DependencyInjection/AutocompleteFormTypePass.php b/src/Autocomplete/src/DependencyInjection/AutocompleteFormTypePass.php
new file mode 100644
index 00000000000..e5cf657113d
--- /dev/null
+++ b/src/Autocomplete/src/DependencyInjection/AutocompleteFormTypePass.php
@@ -0,0 +1,86 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\Autocomplete\DependencyInjection;
+
+use Symfony\Component\DependencyInjection\ChildDefinition;
+use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
+use Symfony\Component\DependencyInjection\Compiler\ServiceLocatorTagPass;
+use Symfony\Component\DependencyInjection\ContainerBuilder;
+use Symfony\Component\DependencyInjection\Definition;
+use Symfony\Component\DependencyInjection\Reference;
+use Symfony\UX\Autocomplete\Form\AsEntityAutocompleteField;
+
+/**
+ * @author Ryan Weaver
+ *
+ * @experimental
+ */
+class AutocompleteFormTypePass implements CompilerPassInterface
+{
+ /** @var string Tag applied to form types that will be used for autocompletion */
+ public const ENTITY_AUTOCOMPLETE_FIELD_TAG = 'ux.entity_autocomplete_field';
+ /** @var string Tag applied to EntityAutocompleterInterface classes */
+ public const ENTITY_AUTOCOMPLETER_TAG = 'ux.entity_autocompleter';
+
+ public function process(ContainerBuilder $container)
+ {
+ $this->processEntityAutocompleteFieldTag($container);
+ $this->processEntityAutocompleterTag($container);
+ }
+
+ private function processEntityAutocompleteFieldTag(ContainerBuilder $container)
+ {
+ foreach ($container->findTaggedServiceIds(self::ENTITY_AUTOCOMPLETE_FIELD_TAG, true) as $serviceId => $tag) {
+ $serviceDefinition = $container->getDefinition($serviceId);
+ if (!$serviceDefinition->hasTag('form.type')) {
+ 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));
+ }
+ $alias = $this->getAlias($serviceId, $serviceDefinition, $tag);
+
+ $wrappedDefinition = (new ChildDefinition('ux.autocomplete.wrapped_entity_type_autocompleter'))
+ // the "formType" string
+ ->replaceArgument(0, $serviceDefinition->getClass())
+ ->addTag(self::ENTITY_AUTOCOMPLETER_TAG, ['alias' => $alias]);
+ $container->setDefinition('ux.autocomplete.wrapped_entity_type_autocompleter.'.$alias, $wrappedDefinition);
+ }
+ }
+
+ private function getAlias(string $serviceId, Definition $serviceDefinition, array $tag): string
+ {
+ if ($tag[0]['alias'] ?? null) {
+ return $tag[0]['alias'];
+ }
+
+ $class = $serviceDefinition->getClass();
+ $attribute = AsEntityAutocompleteField::getInstance($class);
+ if (null === $attribute) {
+ 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));
+ }
+
+ return $attribute->getAlias() ?: AsEntityAutocompleteField::shortName($class);
+ }
+
+ private function processEntityAutocompleterTag(ContainerBuilder $container)
+ {
+ $servicesMap = [];
+ foreach ($container->findTaggedServiceIds(self::ENTITY_AUTOCOMPLETER_TAG, true) as $serviceId => $tag) {
+ if (!isset($tag[0]['alias'])) {
+ throw new \LogicException(sprintf('The "%s" tag of the "%s" service needs "alias" key.', self::ENTITY_AUTOCOMPLETER_TAG, $serviceId));
+ }
+
+ $servicesMap[$tag[0]['alias']] = new Reference($serviceId);
+ }
+
+ $definition = $container->findDefinition('ux.autocomplete.autocompleter_registry');
+ $definition->setArgument(0, ServiceLocatorTagPass::register($container, $servicesMap));
+ }
+}
diff --git a/src/Autocomplete/src/Doctrine/DoctrineRegistryWrapper.php b/src/Autocomplete/src/Doctrine/DoctrineRegistryWrapper.php
new file mode 100644
index 00000000000..2beeb54db6b
--- /dev/null
+++ b/src/Autocomplete/src/Doctrine/DoctrineRegistryWrapper.php
@@ -0,0 +1,50 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\Autocomplete\Doctrine;
+
+use Doctrine\ORM\EntityManagerInterface;
+use Doctrine\ORM\EntityRepository;
+use Symfony\Bridge\Doctrine\ManagerRegistry;
+
+/**
+ * Small wrapper around ManagerRegistry to help if Doctrine is missing.
+ *
+ * @author Ryan Weaver
+ *
+ * @experimental
+ */
+class DoctrineRegistryWrapper
+{
+ public function __construct(
+ private ?ManagerRegistry $registry = null
+ ) {
+ }
+
+ public function getRepository(string $class): EntityRepository
+ {
+ return $this->getRegistry()->getRepository($class);
+ }
+
+ public function getManagerForClass(string $class): EntityManagerInterface
+ {
+ return $this->getRegistry()->getManagerForClass($class);
+ }
+
+ private function getRegistry(): ManagerRegistry
+ {
+ if (null === $this->registry) {
+ throw new \LogicException('Doctrine must be installed to use the entity features of AutocompleteBundle.');
+ }
+
+ return $this->registry;
+ }
+}
diff --git a/src/Autocomplete/src/Doctrine/EntityMetadata.php b/src/Autocomplete/src/Doctrine/EntityMetadata.php
new file mode 100644
index 00000000000..2cd3594fb1f
--- /dev/null
+++ b/src/Autocomplete/src/Doctrine/EntityMetadata.php
@@ -0,0 +1,68 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\Autocomplete\Doctrine;
+
+use Doctrine\Persistence\Mapping\ClassMetadata;
+
+/**
+ * @author Ryan Weaver
+ *
+ * @experimental
+ */
+class EntityMetadata
+{
+ public function __construct(
+ private ClassMetadata $metadata
+ ) {
+ }
+
+ public function getAllPropertyNames(): array
+ {
+ return $this->metadata->getFieldNames();
+ }
+
+ public function isAssociation(string $propertyName): bool
+ {
+ return \array_key_exists($propertyName, $this->metadata->associationMappings)
+ || (str_contains($propertyName, '.') && !$this->isEmbeddedClassProperty($propertyName));
+ }
+
+ public function isEmbeddedClassProperty(string $propertyName): bool
+ {
+ $propertyNameParts = explode('.', $propertyName, 2);
+
+ return \array_key_exists($propertyNameParts[0], $this->metadata->embeddedClasses);
+ }
+
+ public function getPropertyMetadata(string $propertyName): array
+ {
+ if (\array_key_exists($propertyName, $this->metadata->fieldMappings)) {
+ return $this->metadata->fieldMappings[$propertyName];
+ }
+
+ if (\array_key_exists($propertyName, $this->metadata->associationMappings)) {
+ return $this->metadata->associationMappings[$propertyName];
+ }
+
+ throw new \InvalidArgumentException(sprintf('The "%s" field does not exist in the "%s" entity.', $propertyName, $this->getFqcn()));
+ }
+
+ public function getPropertyDataType(string $propertyName): string
+ {
+ return $this->getPropertyMetadata($propertyName)['type'];
+ }
+
+ public function getIdValue(object $entity): string
+ {
+ return current($this->metadata->getIdentifierValues($entity));
+ }
+}
diff --git a/src/Autocomplete/src/Doctrine/EntityMetadataFactory.php b/src/Autocomplete/src/Doctrine/EntityMetadataFactory.php
new file mode 100644
index 00000000000..b2838280f4a
--- /dev/null
+++ b/src/Autocomplete/src/Doctrine/EntityMetadataFactory.php
@@ -0,0 +1,56 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\Autocomplete\Doctrine;
+
+use Doctrine\Persistence\Mapping\ClassMetadata;
+use Doctrine\Persistence\ObjectManager;
+
+/**
+ * Adapted from EasyCorp/EasyAdminBundle EntityFactory.
+ *
+ * @experimental
+ */
+class EntityMetadataFactory
+{
+ public function __construct(
+ private DoctrineRegistryWrapper $doctrine
+ ) {
+ }
+
+ public function create(?string $entityFqcn): EntityMetadata
+ {
+ $entityMetadata = $this->getEntityMetadata($entityFqcn);
+
+ return new EntityMetadata($entityMetadata);
+ }
+
+ private function getEntityMetadata(string $entityFqcn): ClassMetadata
+ {
+ $entityManager = $this->getEntityManager($entityFqcn);
+ $entityMetadata = $entityManager->getClassMetadata($entityFqcn);
+
+ if (1 !== \count($entityMetadata->getIdentifierFieldNames())) {
+ throw new \RuntimeException(sprintf('Autocomplete does not support Doctrine entities with composite primary keys (such as the ones used in the "%s" entity).', $entityFqcn));
+ }
+
+ return $entityMetadata;
+ }
+
+ private function getEntityManager(string $entityFqcn): ObjectManager
+ {
+ if (null === $entityManager = $this->doctrine->getManagerForClass($entityFqcn)) {
+ throw new \RuntimeException(sprintf('There is no Doctrine Entity Manager defined for the "%s" class', $entityFqcn));
+ }
+
+ return $entityManager;
+ }
+}
diff --git a/src/Autocomplete/src/Doctrine/EntitySearchUtil.php b/src/Autocomplete/src/Doctrine/EntitySearchUtil.php
new file mode 100644
index 00000000000..da0029f0fad
--- /dev/null
+++ b/src/Autocomplete/src/Doctrine/EntitySearchUtil.php
@@ -0,0 +1,127 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\Autocomplete\Doctrine;
+
+use Doctrine\ORM\QueryBuilder;
+use Symfony\Component\Uid\Ulid;
+use Symfony\Component\Uid\Uuid;
+
+/**
+ * Adapted from EasyCorp/EasyAdminBundle.
+ *
+ * @experimental
+ */
+class EntitySearchUtil
+{
+ public function __construct(private EntityMetadataFactory $metadataFactory)
+ {
+ }
+
+ /**
+ * Adapted from easycorp/easyadmin EntityRepository.
+ */
+ public function addSearchClause(QueryBuilder $queryBuilder, string $query, string $entityClass, array $searchableProperties = null): void
+ {
+ $entityMetadata = $this->metadataFactory->create($entityClass);
+
+ $lowercaseQuery = mb_strtolower($query);
+ $isNumericQuery = is_numeric($query);
+ $isSmallIntegerQuery = ctype_digit($query) && $query >= -32768 && $query <= 32767;
+ $isIntegerQuery = ctype_digit($query) && $query >= -2147483648 && $query <= 2147483647;
+ $isUuidQuery = class_exists(Uuid::class) && Uuid::isValid($query);
+ $isUlidQuery = class_exists(Ulid::class) && Ulid::isValid($query);
+
+ $dqlParameters = [
+ // adding '0' turns the string into a numeric value
+ 'numeric_query' => is_numeric($query) ? 0 + $query : $query,
+ 'uuid_query' => $query,
+ 'text_query' => '%'.$lowercaseQuery.'%',
+ 'words_query' => explode(' ', $lowercaseQuery),
+ ];
+
+ $entitiesAlreadyJoined = [];
+ $searchableProperties = empty($searchableProperties) ? $entityMetadata->getAllPropertyNames() : $searchableProperties;
+ $expressions = [];
+ foreach ($searchableProperties as $propertyName) {
+ if ($entityMetadata->isAssociation($propertyName)) {
+ // support arbitrarily nested associations (e.g. foo.bar.baz.qux)
+ $associatedProperties = explode('.', $propertyName);
+ $numAssociatedProperties = \count($associatedProperties);
+
+ if (1 === $numAssociatedProperties) {
+ 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));
+ }
+
+ $originalPropertyName = $associatedProperties[0];
+ $originalPropertyMetadata = $entityMetadata->getPropertyMetadata($originalPropertyName);
+ $associatedEntityDto = $this->metadataFactory->create($originalPropertyMetadata['targetEntity']);
+
+ for ($i = 0; $i < $numAssociatedProperties - 1; ++$i) {
+ $associatedEntityName = $associatedProperties[$i];
+ $associatedEntityAlias = SearchEscaper::escapeDqlAlias($associatedEntityName);
+ $associatedPropertyName = $associatedProperties[$i + 1];
+
+ if (!\in_array($associatedEntityName, $entitiesAlreadyJoined, true)) {
+ $parentEntityName = 0 === $i ? $queryBuilder->getRootAliases()[0] : $associatedProperties[$i - 1];
+ $queryBuilder->leftJoin($parentEntityName.'.'.$associatedEntityName, $associatedEntityAlias);
+ $entitiesAlreadyJoined[] = $associatedEntityName;
+ }
+
+ if ($i < $numAssociatedProperties - 2) {
+ $propertyMetadata = $associatedEntityDto->getPropertyMetadata($associatedPropertyName);
+ $targetEntity = $propertyMetadata['targetEntity'];
+ $associatedEntityDto = $this->metadataFactory->create($targetEntity);
+ }
+ }
+
+ $entityName = $associatedEntityAlias;
+ $propertyName = $associatedPropertyName;
+ $propertyDataType = $associatedEntityDto->getPropertyDataType($propertyName);
+ } else {
+ $entityName = $queryBuilder->getRootAliases()[0];
+ $propertyDataType = $entityMetadata->getPropertyDataType($propertyName);
+ }
+
+ $isSmallIntegerProperty = 'smallint' === $propertyDataType;
+ $isIntegerProperty = 'integer' === $propertyDataType;
+ $isNumericProperty = \in_array($propertyDataType, ['number', 'bigint', 'decimal', 'float']);
+ // 'citext' is a PostgreSQL extension (https://github.com/EasyCorp/EasyAdminBundle/issues/2556)
+ $isTextProperty = \in_array($propertyDataType, ['string', 'text', 'citext', 'array', 'simple_array']);
+ $isGuidProperty = \in_array($propertyDataType, ['guid', 'uuid']);
+ $isUlidProperty = 'ulid' === $propertyDataType;
+
+ // this complex condition is needed to avoid issues on PostgreSQL databases
+ if (
+ ($isSmallIntegerProperty && $isSmallIntegerQuery) ||
+ ($isIntegerProperty && $isIntegerQuery) ||
+ ($isNumericProperty && $isNumericQuery)
+ ) {
+ $expressions[] = $queryBuilder->expr()->eq(sprintf('%s.%s', $entityName, $propertyName), ':query_for_numbers');
+ $queryBuilder->setParameter('query_for_numbers', $dqlParameters['numeric_query']);
+ } elseif ($isGuidProperty && $isUuidQuery) {
+ $expressions[] = $queryBuilder->expr()->eq(sprintf('%s.%s', $entityName, $propertyName), ':query_for_uuids');
+ $queryBuilder->setParameter('query_for_uuids', $dqlParameters['uuid_query'], 'uuid' === $propertyDataType ? 'uuid' : null);
+ } elseif ($isUlidProperty && $isUlidQuery) {
+ $expressions[] = $queryBuilder->expr()->eq(sprintf('%s.%s', $entityName, $propertyName), ':query_for_uuids');
+ $queryBuilder->setParameter('query_for_uuids', $dqlParameters['uuid_query'], 'ulid');
+ } elseif ($isTextProperty) {
+ $expressions[] = $queryBuilder->expr()->like(sprintf('LOWER(%s.%s)', $entityName, $propertyName), ':query_for_text');
+ $queryBuilder->setParameter('query_for_text', $dqlParameters['text_query']);
+
+ $expressions[] = $queryBuilder->expr()->in(sprintf('LOWER(%s.%s)', $entityName, $propertyName), ':query_as_words');
+ $queryBuilder->setParameter('query_as_words', $dqlParameters['words_query']);
+ }
+ }
+
+ $queryBuilder->andWhere($queryBuilder->expr()->orX(...$expressions));
+ }
+}
diff --git a/src/Autocomplete/src/Doctrine/SearchEscaper.php b/src/Autocomplete/src/Doctrine/SearchEscaper.php
new file mode 100644
index 00000000000..5452c7c276d
--- /dev/null
+++ b/src/Autocomplete/src/Doctrine/SearchEscaper.php
@@ -0,0 +1,59 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\Autocomplete\Doctrine;
+
+use Doctrine\ORM\Query\Lexer;
+
+/**
+ * Adapted from EasyCorp/EasyAdminBundle Escaper.
+ *
+ * @experimental
+ */
+class SearchEscaper
+{
+ public const DQL_ALIAS_PREFIX = 'autocomplete_';
+
+ /**
+ * Some words (e.g. "order") are reserved keywords in the DQL (Doctrine Query Language).
+ * That's why when using entity names as DQL aliases, we need to escape
+ * those reserved keywords.
+ *
+ * This method ensures that the given entity name can be used as a DQL alias.
+ * Most of them are left unchanged (e.g. "category" or "invoice") but others
+ * will include a prefix to escape them (e.g. "order" becomes "autocomplete_order").
+ */
+ public static function escapeDqlAlias(string $entityName): string
+ {
+ if (self::isDqlReservedKeyword($entityName)) {
+ return self::DQL_ALIAS_PREFIX.$entityName;
+ }
+
+ return $entityName;
+ }
+
+ /**
+ * Determines if a string is a reserved keyword in DQL (Doctrine Query Language).
+ */
+ private static function isDqlReservedKeyword(string $string): bool
+ {
+ $lexer = new Lexer($string);
+
+ $lexer->moveNext();
+ $token = $lexer->lookahead;
+
+ if (200 <= $token['type']) {
+ return true;
+ }
+
+ return false;
+ }
+}
diff --git a/src/Autocomplete/src/EntityAutocompleterInterface.php b/src/Autocomplete/src/EntityAutocompleterInterface.php
new file mode 100644
index 00000000000..50eebfb27a2
--- /dev/null
+++ b/src/Autocomplete/src/EntityAutocompleterInterface.php
@@ -0,0 +1,56 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\Autocomplete;
+
+use Doctrine\ORM\EntityRepository;
+use Doctrine\ORM\QueryBuilder;
+use Symfony\Component\Security\Core\Security;
+
+/**
+ * Interface for classes that will have an "autocomplete" endpoint exposed.
+ */
+interface EntityAutocompleterInterface
+{
+ /**
+ * The fully-qualified entity class this will be autocompleting.
+ */
+ public function getEntityClass(): string;
+
+ /**
+ * A query builder that would return all potential results.
+ */
+ public function getQueryBuilder(EntityRepository $repository): QueryBuilder;
+
+ /**
+ * Returns the "choice_label" used to display this entity.
+ */
+ public function getLabel(object $entity): string;
+
+ /**
+ * Returns the "value" attribute for this entity, usually the id.
+ */
+ public function getValue(object $entity): mixed;
+
+ /**
+ * Return an array of the fields to search.
+ *
+ * If null is returned, all fields are searched.
+ */
+ public function getSearchableFields(): ?array;
+
+ /**
+ * Return true if access should be granted to the autocomplete results for the current user.
+ *
+ * Note: if SecurityBundle is not installed, this will not be called.
+ */
+ public function isGranted(Security $security): bool;
+}
diff --git a/src/Autocomplete/src/Form/AsEntityAutocompleteField.php b/src/Autocomplete/src/Form/AsEntityAutocompleteField.php
new file mode 100644
index 00000000000..d1d1c13839a
--- /dev/null
+++ b/src/Autocomplete/src/Form/AsEntityAutocompleteField.php
@@ -0,0 +1,55 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\Autocomplete\Form;
+
+use Symfony\Component\String\UnicodeString;
+
+/**
+ * All form types that want to expose autocomplete functionality should have this.
+ *
+ * @author Ryan Weaver
+ *
+ * @experimental
+ */
+#[\Attribute(\Attribute::TARGET_CLASS)]
+class AsEntityAutocompleteField
+{
+ public function __construct(
+ private ?string $alias = null,
+ ) {
+ }
+
+ public function getAlias(): ?string
+ {
+ return $this->alias;
+ }
+
+ public static function shortName(string $class): string
+ {
+ $string = new UnicodeString($class);
+
+ return $string->afterLast('\\')->snake()->toString();
+ }
+
+ public static function getInstance(string $class): ?self
+ {
+ $reflectionClass = new \ReflectionClass($class);
+
+ $attributes = $reflectionClass->getAttributes(self::class);
+
+ if (0 === \count($attributes)) {
+ return null;
+ }
+
+ return $attributes[0]->newInstance();
+ }
+}
diff --git a/src/Autocomplete/src/Form/AutocompleteChoiceTypeExtension.php b/src/Autocomplete/src/Form/AutocompleteChoiceTypeExtension.php
new file mode 100644
index 00000000000..cb0b98d4538
--- /dev/null
+++ b/src/Autocomplete/src/Form/AutocompleteChoiceTypeExtension.php
@@ -0,0 +1,102 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\Autocomplete\Form;
+
+use Symfony\Component\Form\AbstractTypeExtension;
+use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
+use Symfony\Component\Form\Extension\Core\Type\TextType;
+use Symfony\Component\Form\FormInterface;
+use Symfony\Component\Form\FormView;
+use Symfony\Component\OptionsResolver\Options;
+use Symfony\Component\OptionsResolver\OptionsResolver;
+use Symfony\Contracts\Translation\TranslatorInterface;
+
+/**
+ * Initializes the autocomplete Stimulus controller for any fields with the "autocomplete" option.
+ *
+ * @internal
+ */
+final class AutocompleteChoiceTypeExtension extends AbstractTypeExtension
+{
+ public function __construct(private ?TranslatorInterface $translator = null)
+ {
+ }
+
+ public static function getExtendedTypes(): iterable
+ {
+ return [
+ ChoiceType::class,
+ TextType::class,
+ ];
+ }
+
+ public function finishView(FormView $view, FormInterface $form, array $options)
+ {
+ if (!$options['autocomplete']) {
+ return;
+ }
+
+ $attr = $view->vars['attr'] ?? [];
+
+ $controllerName = 'symfony--ux-autocomplete--autocomplete';
+ $attr['data-controller'] = trim(($attr['data-controller'] ?? '').' '.$controllerName);
+
+ $values = [];
+ if ($options['autocomplete_url']) {
+ $values['url'] = $options['autocomplete_url'];
+ }
+
+ if ($options['options_as_html']) {
+ $values['options-as-html'] = '';
+ }
+
+ if ($options['allow_options_create']) {
+ $values['allow-options-create'] = '';
+ }
+
+ if ($options['tom_select_options']) {
+ $values['tom-select-options'] = json_encode($options['tom_select_options']);
+ }
+
+ $values['no-results-found-text'] = $this->trans($options['no_results_found_text']);
+ $values['no-more-results-text'] = $this->trans($options['no_more_results_text']);
+
+ foreach ($values as $name => $value) {
+ $attr['data-'.$controllerName.'-'.$name.'-value'] = $value;
+ }
+
+ $view->vars['attr'] = $attr;
+ }
+
+ public function configureOptions(OptionsResolver $resolver)
+ {
+ $resolver->setDefaults([
+ 'autocomplete' => false,
+ 'autocomplete_url' => null,
+ 'tom_select_options' => [],
+ 'options_as_html' => false,
+ 'allow_options_create' => false,
+ 'no_results_found_text' => 'No results found',
+ 'no_more_results_text' => 'No more results',
+ ]);
+
+ // if autocomplete_url is passed, then HTML options are already supported
+ $resolver->setNormalizer('options_as_html', function (Options $options, $value) {
+ return null === $options['autocomplete_url'] ? $value : false;
+ });
+ }
+
+ private function trans(string $message): string
+ {
+ return $this->translator ? $this->translator->trans($message, [], 'AutocompleteBundle') : $message;
+ }
+}
diff --git a/src/Autocomplete/src/Form/AutocompleteEntityTypeSubscriber.php b/src/Autocomplete/src/Form/AutocompleteEntityTypeSubscriber.php
new file mode 100644
index 00000000000..a2c77600208
--- /dev/null
+++ b/src/Autocomplete/src/Form/AutocompleteEntityTypeSubscriber.php
@@ -0,0 +1,74 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\Autocomplete\Form;
+
+use Symfony\Bridge\Doctrine\Form\Type\EntityType;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+use Symfony\Component\Form\FormEvent;
+use Symfony\Component\Form\FormEvents;
+
+/**
+ * Helps transform ParentEntityAutocompleteType into a EntityType that will not load all options.
+ *
+ * @internal
+ */
+final class AutocompleteEntityTypeSubscriber implements EventSubscriberInterface
+{
+ public function __construct(
+ private ?string $autocompleteUrl = null
+ ) {
+ }
+
+ public function preSetData(FormEvent $event)
+ {
+ $form = $event->getForm();
+ $data = $event->getData() ?: [];
+
+ $options = $form->getConfig()->getOptions();
+ $options['compound'] = false;
+ $options['choices'] = is_iterable($data) ? $data : [$data];
+ // pass to AutocompleteChoiceTypeExtension
+ $options['autocomplete'] = true;
+ $options['autocomplete_url'] = $this->autocompleteUrl;
+ unset($options['searchable_fields'], $options['security']);
+
+ $form->add('autocomplete', EntityType::class, $options);
+ }
+
+ public function preSubmit(FormEvent $event)
+ {
+ $data = $event->getData();
+ $form = $event->getForm();
+ $options = $form->get('autocomplete')->getConfig()->getOptions();
+
+ if (!isset($data['autocomplete']) || '' === $data['autocomplete']) {
+ $options['choices'] = [];
+ } else {
+ $options['choices'] = $options['em']->getRepository($options['class'])->findBy([
+ $options['id_reader']->getIdField() => $data['autocomplete'],
+ ]);
+ }
+
+ // reset some critical lazy options
+ unset($options['em'], $options['loader'], $options['empty_data'], $options['choice_list'], $options['choices_as_values']);
+
+ $form->add('autocomplete', EntityType::class, $options);
+ }
+
+ public static function getSubscribedEvents(): array
+ {
+ return [
+ FormEvents::PRE_SET_DATA => 'preSetData',
+ FormEvents::PRE_SUBMIT => 'preSubmit',
+ ];
+ }
+}
diff --git a/src/Autocomplete/src/Form/ParentEntityAutocompleteType.php b/src/Autocomplete/src/Form/ParentEntityAutocompleteType.php
new file mode 100644
index 00000000000..69652a6dd9b
--- /dev/null
+++ b/src/Autocomplete/src/Form/ParentEntityAutocompleteType.php
@@ -0,0 +1,89 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\Autocomplete\Form;
+
+use Symfony\Component\Form\AbstractType;
+use Symfony\Component\Form\DataMapperInterface;
+use Symfony\Component\Form\FormBuilderInterface;
+use Symfony\Component\Form\FormInterface;
+use Symfony\Component\Form\FormView;
+use Symfony\Component\OptionsResolver\OptionsResolver;
+use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
+
+/**
+ * All form types that want to expose autocomplete functionality should use this for its getParent().
+ */
+final class ParentEntityAutocompleteType extends AbstractType implements DataMapperInterface
+{
+ public function __construct(
+ private UrlGeneratorInterface $urlGenerator
+ ) {
+ }
+
+ public function buildForm(FormBuilderInterface $builder, array $options)
+ {
+ $formType = $builder->getType()->getInnerType();
+ $attribute = AsEntityAutocompleteField::getInstance(\get_class($formType));
+ if (!$attribute) {
+ throw new \LogicException(sprintf('The %s class must have a #[AsEntityAutocompleteField] attribute above its class.', \get_class($formType)));
+ }
+
+ $autocompleteUrl = $this->urlGenerator->generate('ux_entity_autocomplete', [
+ 'alias' => $attribute->getAlias() ?: AsEntityAutocompleteField::shortName(\get_class($formType)),
+ ]);
+ $builder
+ ->addEventSubscriber(new AutocompleteEntityTypeSubscriber($autocompleteUrl))
+ ->setDataMapper($this);
+ }
+
+ public function finishView(FormView $view, FormInterface $form, array $options)
+ {
+ // Add a custom block prefix to inner field to ease theming:
+ array_splice($view['autocomplete']->vars['block_prefixes'], -1, 0, 'ux_entity_autocomplete_inner');
+ // this IS A compound (i.e. has children) field
+ // however, we only render the child "autocomplete" field. So for rendering, fake NOT compound
+ $view->vars['compound'] = false;
+ }
+
+ public function configureOptions(OptionsResolver $resolver)
+ {
+ $resolver->setDefaults([
+ 'multiple' => false,
+ // force display errors on this form field
+ 'error_bubbling' => false,
+ 'searchable_fields' => null,
+ // set to the string role that's required to view the autocomplete results
+ // or a callable: function(Symfony\Component\Security\Core\Security $security): bool
+ 'security' => false,
+ ]);
+
+ $resolver->setRequired(['class']);
+ $resolver->setAllowedTypes('security', ['boolean', 'string', 'callable']);
+ }
+
+ public function getBlockPrefix(): string
+ {
+ return 'ux_entity_autocomplete';
+ }
+
+ public function mapDataToForms($data, $forms)
+ {
+ $form = current(iterator_to_array($forms, false));
+ $form->setData($data);
+ }
+
+ public function mapFormsToData($forms, &$data)
+ {
+ $form = current(iterator_to_array($forms, false));
+ $data = $form->getData();
+ }
+}
diff --git a/src/Autocomplete/src/Form/WrappedEntityTypeAutocompleter.php b/src/Autocomplete/src/Form/WrappedEntityTypeAutocompleter.php
new file mode 100644
index 00000000000..aa6fdeb0ddd
--- /dev/null
+++ b/src/Autocomplete/src/Form/WrappedEntityTypeAutocompleter.php
@@ -0,0 +1,118 @@
+getFormOption('class');
+ }
+
+ public function getQueryBuilder(EntityRepository $repository): QueryBuilder
+ {
+ if ($queryBuilder = $this->getFormOption('query_builder')) {
+ return $queryBuilder;
+ }
+
+ return $repository->createQueryBuilder('e');
+ }
+
+ public function getLabel(object $entity): string
+ {
+ $choiceLabel = $this->getFormOption('choice_label');
+
+ if (null === $choiceLabel) {
+ return (string) $entity;
+ }
+
+ if (\is_string($choiceLabel) || $choiceLabel instanceof PropertyPathInterface) {
+ return $this->propertyAccessor->getValue($entity, $choiceLabel);
+ }
+
+ // 0 hardcoded as the "index", should not be relevant
+ return $choiceLabel($entity, 0, $this->getValue($entity));
+ }
+
+ public function getValue(object $entity): string
+ {
+ return $this->getEntityMetadata()->getIdValue($entity);
+ }
+
+ public function getSearchableFields(): ?array
+ {
+ return $this->getForm()->getConfig()->getOption('searchable_fields');
+ }
+
+ public function isGranted(Security $security): bool
+ {
+ $securityOption = $this->getForm()->getConfig()->getOption('security');
+
+ if (false === $securityOption) {
+ return true;
+ }
+
+ if (\is_string($securityOption)) {
+ return $security->isGranted($securityOption, $this);
+ }
+
+ if (\is_callable($securityOption)) {
+ return $securityOption($security);
+ }
+
+ throw new \InvalidArgumentException('Invalid passed to the "security" option: it must be the boolean true, a string role or a callable.');
+ }
+
+ private function getFormOption(string $name): mixed
+ {
+ $form = $this->getForm();
+ $formOptions = $form['autocomplete']->getConfig()->getOptions();
+
+ return $formOptions[$name] ?? null;
+ }
+
+ private function getForm(): FormInterface
+ {
+ if (null === $this->form) {
+ $this->form = $this->formFactory->create($this->formType);
+ }
+
+ return $this->form;
+ }
+
+ private function getEntityMetadata(): EntityMetadata
+ {
+ if (null === $this->entityMetadata) {
+ $this->entityMetadata = $this->metadataFactory->create($this->getEntityClass());
+ }
+
+ return $this->entityMetadata;
+ }
+}
diff --git a/src/Autocomplete/src/Maker/MakeAutocompleteField.php b/src/Autocomplete/src/Maker/MakeAutocompleteField.php
new file mode 100644
index 00000000000..b165d087921
--- /dev/null
+++ b/src/Autocomplete/src/Maker/MakeAutocompleteField.php
@@ -0,0 +1,155 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\Autocomplete\Maker;
+
+use Doctrine\ORM\EntityManagerInterface;
+use Symfony\Bundle\MakerBundle\ConsoleStyle;
+use Symfony\Bundle\MakerBundle\DependencyBuilder;
+use Symfony\Bundle\MakerBundle\Doctrine\DoctrineHelper;
+use Symfony\Bundle\MakerBundle\Generator;
+use Symfony\Bundle\MakerBundle\InputConfiguration;
+use Symfony\Bundle\MakerBundle\Maker\AbstractMaker;
+use Symfony\Bundle\MakerBundle\Str;
+use Symfony\Bundle\MakerBundle\Util\UseStatementGenerator;
+use Symfony\Bundle\MakerBundle\Validator;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Question\Question;
+use Symfony\Component\Form\AbstractType;
+use Symfony\Component\Form\FormInterface;
+use Symfony\Component\OptionsResolver\OptionsResolver;
+use Symfony\UX\Autocomplete\Form\AsEntityAutocompleteField;
+use Symfony\UX\Autocomplete\Form\ParentEntityAutocompleteType;
+
+/**
+ * @author Ryan Weaver
+ *
+ * @experimental
+ */
+class MakeAutocompleteField extends AbstractMaker
+{
+ private string $className;
+ private string $entityClass;
+
+ public function __construct(
+ private ?DoctrineHelper $doctrineHelper = null
+ ) {
+ }
+
+ public static function getCommandName(): string
+ {
+ return 'make:autocomplete-field';
+ }
+
+ public static function getCommandDescription(): string
+ {
+ return 'Generates an Ajax-autocomplete form field class for symfony/ux-autocomplete.';
+ }
+
+ public function configureCommand(Command $command, InputConfiguration $inputConfig)
+ {
+ $command
+ ->setHelp(<<%command.name% command generates an Ajax-autocomplete form field class for symfony/ux-autocomplete
+
+php %command.full_name%
+
+The command will ask you which entity the field is for and what to call your new class.
+EOF)
+ ;
+ }
+
+ public function configureDependencies(DependencyBuilder $dependencies)
+ {
+ $dependencies->addClassDependency(FormInterface::class, 'symfony/form');
+ }
+
+ public function interact(InputInterface $input, ConsoleStyle $io, Command $command)
+ {
+ if (null === $this->doctrineHelper) {
+ throw new \LogicException('Somehow the DoctrineHelper service is missing from MakerBundle.');
+ }
+
+ $entities = $this->doctrineHelper->getEntitiesForAutocomplete();
+
+ $question = new Question('The class name of the entity you want to autocomplete');
+ $question->setAutocompleterValues($entities);
+ $question->setValidator(function ($choice) use ($entities) {
+ return Validator::entityExists($choice, $entities);
+ });
+
+ $this->entityClass = $io->askQuestion($question);
+
+ $defaultClass = Str::asClassName(sprintf('%s AutocompleteField', $this->entityClass));
+ $this->className = $io->ask(
+ sprintf('Choose a name for your entity field class (e.g. %s>)', $defaultClass),
+ $defaultClass
+ );
+ }
+
+ public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator)
+ {
+ if (null === $this->doctrineHelper) {
+ throw new \LogicException('Somehow the DoctrineHelper service is missing from MakerBundle.');
+ }
+
+ $entityClassDetails = $generator->createClassNameDetails(
+ $this->entityClass,
+ 'Entity\\'
+ );
+ $entityDoctrineDetails = $this->doctrineHelper->createDoctrineDetails($entityClassDetails->getFullName());
+
+ $classDetails = $generator->createClassNameDetails(
+ $this->className,
+ 'Form\\',
+ );
+
+ $repositoryClassDetails = $entityDoctrineDetails->getRepositoryClass() ? $generator->createClassNameDetails('\\'.$entityDoctrineDetails->getRepositoryClass(), '') : null;
+
+ // use App\Entity\Category;
+ // use App\Repository\CategoryRepository;
+ $useStatements = new UseStatementGenerator([
+ $entityClassDetails->getFullName(),
+ $repositoryClassDetails ? $repositoryClassDetails->getFullName() : EntityManagerInterface::class,
+ AbstractType::class,
+ OptionsResolver::class,
+ AsEntityAutocompleteField::class,
+ ParentEntityAutocompleteType::class,
+ ]);
+
+ $variables = new MakerAutocompleteVariables(
+ useStatements: $useStatements,
+ entityClassDetails: $entityClassDetails,
+ repositoryClassDetails: $repositoryClassDetails,
+ );
+ $generator->generateClass(
+ $classDetails->getFullName(),
+ __DIR__.'/../Resources/skeletons/AutocompleteField.tpl.php',
+ [
+ 'variables' => $variables,
+ ]
+ );
+
+ $generator->writeChanges();
+
+ $this->writeSuccessMessage($io);
+
+ $io->text([
+ 'Customize your new field class, then add it to a form:',
+ '',
+ ' $builder ',
+ ' // ... ',
+ sprintf(' ->add(\'%s\', %s::class) ', Str::asLowerCamelCase($entityClassDetails->getShortName()), $classDetails->getShortName()),
+ ' ;>',
+ ]);
+ }
+}
diff --git a/src/Autocomplete/src/Maker/MakerAutocompleteVariables.php b/src/Autocomplete/src/Maker/MakerAutocompleteVariables.php
new file mode 100644
index 00000000000..a4bd24f5b74
--- /dev/null
+++ b/src/Autocomplete/src/Maker/MakerAutocompleteVariables.php
@@ -0,0 +1,28 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\Autocomplete\Maker;
+
+use Symfony\Bundle\MakerBundle\Util\ClassNameDetails;
+use Symfony\Bundle\MakerBundle\Util\UseStatementGenerator;
+
+/**
+ * @internal
+ */
+class MakerAutocompleteVariables
+{
+ public function __construct(
+ public UseStatementGenerator $useStatements,
+ public ClassNameDetails $entityClassDetails,
+ public ?ClassNameDetails $repositoryClassDetails = null,
+ ) {
+ }
+}
diff --git a/src/Autocomplete/src/Resources/doc/food-non-ajax.png b/src/Autocomplete/src/Resources/doc/food-non-ajax.png
new file mode 100644
index 00000000000..08d55d92aab
Binary files /dev/null and b/src/Autocomplete/src/Resources/doc/food-non-ajax.png differ
diff --git a/src/Autocomplete/src/Resources/doc/index.rst b/src/Autocomplete/src/Resources/doc/index.rst
new file mode 100644
index 00000000000..fcd70cb26e0
--- /dev/null
+++ b/src/Autocomplete/src/Resources/doc/index.rst
@@ -0,0 +1,494 @@
+Autocomplete
+=====================
+
+Transform your ``EntityType``, ``ChoiceType`` or *any* ```` element
+into an Ajax-powered autocomplete smart UI control (leveraging `Tom Select`_):
+
+.. image:: ux-autocomplete-animation.gif
+ :alt: Demo of an autocomplete-enabled select element
+ :align: center
+ :width: 300
+
+**EXPERIMENTAL** This component is currently experimental and is likely
+to change, or even change drastically.
+
+Installation
+------------
+
+Before you start, make sure you have `Symfony UX configured in your app`_.
+
+Then install the bundle using Composer and Symfony Flex:
+
+.. code-block:: terminal
+
+ $ composer require symfony/ux-autocomplete
+
+ # Don't forget to install the JavaScript dependencies as well and compile
+ $ yarn install --force
+ $ yarn watch
+
+ # or use npm
+ $ npm install --force
+ $ npm run watch
+
+Usage in a Form (without Ajax)
+------------------------------
+
+Any ``ChoiceType`` or ``EntityType`` can be transformed into a
+Tom Select-powered UI control by adding the ``autocomplete`` option:
+
+.. code-block:: diff
+
+ // src/Form/AnyForm.php
+ // ...
+
+ class AnyForm extends AbstractType
+ {
+ public function buildForm(FormBuilderInterface $builder, array $options): void
+ {
+ $builder
+ ->add('food', EntityType::class, [
+ 'class' => Food::class,
+ 'placeholder' => 'What should we eat?',
+ + 'autocomplete' => true,
+ ])
+
+ ->add('portionSize', ChoiceType::class, [
+ 'choices' => [
+ 'Choose a portion size' => '',,
+ 'small' => 's',
+ 'medium' => 'm',
+ 'large' => 'l',
+ 'extra large' => 'xl',
+ 'all you can eat' => '∞',
+ ],
+ + 'autocomplete' => true,
+ ])
+ ;
+ }
+ }
+
+That's all you need! When you refresh, the Autocomplete Stimulus controller
+will transform your select element into a smart UI control:
+
+.. image:: food-non-ajax.png
+ :alt: Screenshot of a Food select with Tom Select
+ :align: center
+ :width: 300
+
+Usage in a Form (with Ajax)
+---------------------------
+
+In the previous example, the autocomplete happens "locally":
+all of the options are loaded onto the page and used for the
+search.
+
+If you're using an ``EntityType`` with *many* possible options,
+a better option is to load the choices via AJAX. This also allows
+you to search on more fields than just the "displayed" text.
+
+To transform your field into an Ajax-powered autocomplete, you need
+to create a new "form type" class to represent your field. If you
+have MakerBundle installed, you can run:
+
+.. code-block:: terminal
+
+ $ php bin/console make:autocomplete-field
+
+Or, create the field by hand::
+
+ // src/Form/FoodAutocompleteField.php
+ // ...
+
+ use Symfony\Component\Security\Core\Security;
+ use Symfony\UX\Autocomplete\Form\AsEntityAutocompleteField;
+ use Symfony\UX\Autocomplete\Form\ParentEntityAutocompleteType;
+
+ #[AsEntityAutocompleteField]
+ class FoodAutocompleteField extends AbstractType
+ {
+ public function configureOptions(OptionsResolver $resolver)
+ {
+ $resolver->setDefaults([
+ 'class' => Food::class,
+ 'placeholder' => 'What should we eat?',
+
+ // choose which fields to use in the search
+ // if not passed, *all* fields are used
+ //'searchable_fields' => ['name'],
+
+ // if the autocomplete endpoint needs to be secured
+ //'security' => 'ROLE_FOOD_ADMIN',
+
+ // ... any other normal EntityType options
+ // e.g. query_builder, choice_label
+ ]);
+ }
+
+ public function getParent(): string
+ {
+ return ParentEntityAutocompleteType::class;
+ }
+ }
+
+There are 3 important things:
+
+#. The class needs the ``#[AsEntityAutocompleteField]`` attribute so that
+ it's noticed by the autocomplete system.
+#. The ``getParent()`` method must return ``ParentEntityAutocompleteType``.
+#. Inside ``configureOptions()``, you can configure your field using whatever
+ normal ``EntityType`` options you need plus a few extra options (see `Form Options Reference`_).
+
+After creating this class, use it in your form:
+
+.. code-block:: diff
+
+ // src/Form/AnyForm.php
+ // ...
+
+ class AnyForm extends AbstractType
+ {
+ public function buildForm(FormBuilderInterface $builder, array $options): void
+ {
+ $builder
+ + ->add('food', FoodAutocompleteField::class)
+ ;
+ }
+ }
+
+For consistent results, avoid passing any options to the 3rd argument
+of the ``->add()`` method. Instead, include all options inside the
+custom class (``FoodAutocompleteField``).
+
+Congratulations! Your ``EntityType`` is now Ajax-powered!
+
+Styling Tom Select
+------------------
+
+In your ``assets/controllers.json`` file, you should see a line that automatically
+includes a CSS file for Tom Select.
+
+.. code-block:: text
+
+ "autoimport": {
+ "tom-select/dist/css/tom-select.default.css": true
+ }
+
+This should give you basic styles for Tom Select. If you're using
+Bootstrap, you can get Bootstrap-ready styling by changing this
+line to:
+
+.. code-block:: text
+
+ "autoimport": {
+ "tom-select/dist/css/tom-select.bootstrap5.css": true
+ }
+
+To further customize things, you can override the classes with your own custom
+CSS and even control how individual parts of Tom Select render. See `Tom Select Render Templates`_.
+
+Form Options Reference
+----------------------
+
+All ``ChoiceType``, ``EntityType`` and ``TextType`` fields have the following new
+options (these can also be used inside your custom Ajax autocomplete classes,
+e.g. ``FoodAutocompleteField`` from above):
+
+``autocomplete`` (default: ``false``)
+ Set to ``true`` to activate the Stimulus plugin on your ``select`` element.
+
+``tom_select_options`` (default: ``[]``)
+ Use this to set custom `Tom Select Options`_. If you need to set
+ an option using JavaScript, see `Extending Tom Select`_.
+
+``options_as_html`` (default: ``false``)
+ Set to ``true`` if your options (e.g. ``choice_label``) contain HTML. Not
+ needed if your autocomplete is AJAX-powered.
+
+``autocomplete_url`` (default: ``null``)
+ Usually you don't need to set this manually. But, you *could* manually create
+ an autocomplete-Ajax endpoint (e.g. for a custom ``ChoiceType``), then set this
+ to change the field into an AJAX-powered select.
+
+``no_results_found_text`` (default: 'No results found')
+ Rendered when no matching results are found. This message is automatically translated
+ using the ``AutocompleteBundle`` domain.
+
+``no_more_results_text`` (default: 'No more results')
+ Rendered at the bottom of the list after showing matching results. This message
+ is automatically translated using the ``AutocompleteBundle`` domain.
+
+For the Ajax-powered autocomplete field classes (i.e. those whose
+``getParent()`` returns ``ParentEntityAutocompleteType``), in addition
+to the options above, you can also pass:
+
+``searchable_fields`` (default: ``null``)
+ Set this to an array of the fields on your entity that should be used when
+ searching for matching options. By default (i.e. ``null``), *all* fields on your
+ entity will be searched. Relationship fields can also be used - e.g. ``category.name``
+ if your entity has a ``category`` relation property.
+
+``security`` (default: ``false``)
+ Secures the Ajax endpoint. By default, the endpoint can be accessed by
+ any user. To secure it, pass ``security`` to a string role (e.g. ``ROLE_FOOD_ADMIN``)
+ that should be required to access the endpoint. Or, pass a callback and
+ return ``true`` to grant access or ``false`` to deny access::
+
+ use Symfony\Component\Security\Core\Security;
+
+ 'security' => function(Security $security): bool {
+ return $security->isGranted('ROLE_FOO');
+ }
+
+Using with a TextType Field
+---------------------------
+
+All of the above options can also be used with a ``TextType`` field::
+
+ $builder
+ // ...
+ ->add('tags', TextType::class, [
+ 'autocomplete' => true,
+ 'tom_select_options' => [
+ 'create' => true,
+ 'createOnBlur' => true,
+ 'delimiter' => ',',
+ ],
+ // 'autocomplete_url' => '... optional: custom endpoint, see below',
+ ])
+ ;
+
+This `` `` field won't have any autocomplete, but it *will* allow the
+user to enter new options and see them as nice "items" in the box. On submit,
+all of the options - separated by the ``delimiter`` - will be sent as a string.
+
+You *can* add autocompletion to this via the ``autocomplete_url`` option - but you'll
+likely need to create your own :ref:`custom autocomplete endpoint `.
+
+Extending Tom Select
+--------------------
+
+The easiest way to customize `Tom Select`_ is via the ``tom_select_options``
+option that you pass to your field. This works great for simple
+things like Tom Select's ``loadingClass`` option, which is set to a string.
+But other options, like ``onInitialize``, must be set via JavaScript.
+
+To do this, create a custom Stimulus controller and listen to one or both
+events that the core Stimulus controller dispatches:
+
+.. code-block:: javascript
+
+ // assets/controllers/custom-autocomplete_controller.js
+ import { Controller } from '@hotwired/stimulus';
+
+ export default class extends Controller {
+ initialize() {
+ this._onPreConnect = this._onPreConnect.bind(this);
+ this._onConnect = this._onConnect.bind(this);
+ }
+
+ connect() {
+ this.element.addEventListener('autocomplete:pre-connect', this._onPreConnect);
+ this.element.addEventListener('autocomplete:connect', this._onConnect);
+ }
+
+ disconnect() {
+ // You should always remove listeners when the controller is disconnected to avoid side-effects
+ this.element.removeEventListener('autocomplete:pre-connect', this._onConnect);
+ this.element.removeEventListener('autocomplete:connect', this._onPreConnect);
+ }
+
+ _onPreConnect(event) {
+ // TomSelect has not been initialized - options can be changed
+ console.log(event.detail.options); // Options that will be used to initialize TomSelect
+ event.detail.options.onChange = (value) => {
+ // ...
+ });
+ }
+
+ _onConnect(event) {
+ // TomSelect has just been intialized and you can access details from the event
+ console.log(event.detail.tomSelect); // TomSelect instance
+ console.log(event.detail.options); // Options used to initialize TomSelect
+ }
+ }
+
+Then, update your field configuration to use your new controller (it will be used
+in addition to the core Autocomplete controller):
+
+.. code-block:: diff
+
+ $builder
+ ->add('food', EntityType::class, [
+ 'class' => Food::class,
+ + 'attr' => [
+ + 'data-controller' => 'custom-autocomplete',
+ + ],
+ ])
+
+Or, if using a custom Ajax class, add the ``attr`` option to
+your ``configureOptions()`` method:
+
+.. code-block:: diff
+
+ public function configureOptions(OptionsResolver $resolver)
+ {
+ $resolver->setDefaults([
+ 'class' => Food::class,
+ + 'attr' => [
+ + 'data-controller' => 'custom-autocomplete',
+ + ],
+ ]);
+ }
+
+.. _custom-autocompleter:
+
+Advanced: Creating an Autocompleter (with no Form)
+--------------------------------------------------
+
+If you're not using the form system, you can create an Ajax autocomplete
+endpoint and then :ref:`initialize the Stimulus controller manually `.
+This only works for Doctrine entities: see `Manually using the Stimulus Controller`_
+if you're autocompleting something other than an entity.
+
+To expose the endpoint, create a class that implements ``Symfony\\UX\\Autocomplete\\EntityAutocompleterInterface``::
+
+ namespace App\Autocompleter;
+
+ use App\Entity\Food;
+ use Doctrine\ORM\EntityRepository;
+ use Doctrine\ORM\QueryBuilder;
+ use Symfony\Component\Security\Core\Security;
+ use Symfony\UX\Autocomplete\EntityAutocompleterInterface;
+
+ class FoodAutocompleter implements EntityAutocompleterInterface
+ {
+ public function getEntityClass(): string
+ {
+ return Food::class;
+ }
+
+ public function getQueryBuilder(EntityRepository $repository): QueryBuilder
+ {
+ return $repository
+ // the alias "food" can be anything
+ ->createQueryBuilder('food')
+ // andWhere('food.isHealthy = :isHealthy')
+ //->setParameter('isHealthy', true)
+ ;
+ }
+
+ public function getLabel(object $entity): string
+ {
+ return $entity->getName();
+ }
+
+ public function getValue(object $entity): string
+ {
+ return $entity->getId();
+ }
+
+ public function getSearchableFields(): ?array
+ {
+ // see the "searchable_fields" option for details
+ return null;
+ }
+
+ public function isGranted(Security $security): bool
+ {
+ // see the "security" option for details
+ return true;
+ }
+ }
+
+Next, tag this service with ``ux.entity_autocompleter`` and include an ``alias``:
+
+.. code-block:: yaml
+
+ # config/services.yaml
+ services:
+ # ...
+
+ App\Autocompleter\FoodAutocompleter:
+ tags:
+ - { name: ux.entity_autocompleter, alias: 'food' }
+
+Thanks to this, your can now autocomplete your ``Food`` entity via
+the ``ux_entity_autocomplete`` route and ``alias`` route wildcard:
+
+.. code-block:: twig
+
+ {{ path('ux_entity_autocomplete', { alias: 'food' }) }}
+
+Usually, you'll pass this URL to the Stimulus controller, which is
+discussed in the next section.
+
+.. _manual-stimulus-controller:
+
+Manually using the Stimulus Controller
+--------------------------------------
+
+This library comes with a Stimulus controller that can activate
+Tom Select on any ``select`` or ``input`` element. This can be used
+outside of the Form component. For example:
+
+.. code-block:: twig
+
+
+
+That's it! If you want the options to be autocompleted via
+Ajax, pass a ``url`` value, which works well if you create
+a :ref:`custom autocompleter `:
+
+.. code-block:: twig
+
+
+
+.. _custom-autocomplete-endpoint:
+
+.. note::
+
+ If you want to create an AJAX autocomplete endpoint that is
+ *not* for an entity, you will need to create this manually.
+ The only requirement is that the response returns JSON with this format:
+
+ .. code-block:: json
+
+ {
+ "results": [
+ { "value": "1", "text": "Pizza" },
+ { "value": "2", "text":"Banana"}
+ ]
+ }
+
+ Once you have this, generate the URL to your controller and
+ pass it to the ``url`` value of the ``stimulus_controller()`` Twig
+ function, or to the ``autocomplete_url`` option of your form field.
+
+Beyond ``url``, the Stimulus controller has various other values,
+including ``tomSelectOptions``. See the `controller.ts`_ file for
+the full list.
+
+Backward Compatibility promise
+------------------------------
+
+This bundle aims at following the same Backward Compatibility promise as
+the Symfony framework: https://symfony.com/doc/current/contributing/code/bc.html
+
+However it is currently considered `experimental`_, meaning it is not bound
+to Symfony's BC policy for the moment.
+
+.. _`Tom Select`: https://tom-select.js.org/
+.. _`Symfony UX configured in your app`: https://symfony.com/doc/current/frontend/ux.html
+.. _`Tom Select Options`: https://tom-select.js.org/docs/#general-configuration
+.. _`controller.ts`: https://github.com/symfony/ux/blob/2.x/src/Autocomplete/assets/src/controller.ts
+.. _`experimental`: https://symfony.com/doc/current/contributing/code/experimental.html
+.. _`Tom Select Render Templates`: https://tom-select.js.org/docs/#render-templates
diff --git a/src/Autocomplete/src/Resources/doc/ux-autocomplete-animation.gif b/src/Autocomplete/src/Resources/doc/ux-autocomplete-animation.gif
new file mode 100644
index 00000000000..41f6824dc81
Binary files /dev/null and b/src/Autocomplete/src/Resources/doc/ux-autocomplete-animation.gif differ
diff --git a/src/Autocomplete/src/Resources/routes.php b/src/Autocomplete/src/Resources/routes.php
new file mode 100644
index 00000000000..620d1a9fded
--- /dev/null
+++ b/src/Autocomplete/src/Resources/routes.php
@@ -0,0 +1,9 @@
+add('ux_entity_autocomplete', '/{alias}')
+ ->controller('ux.autocomplete.entity_autocomplete_controller')
+ ;
+};
diff --git a/src/Autocomplete/src/Resources/skeletons/AutocompleteField.tpl.php b/src/Autocomplete/src/Resources/skeletons/AutocompleteField.tpl.php
new file mode 100644
index 00000000000..2d79c4be4a2
--- /dev/null
+++ b/src/Autocomplete/src/Resources/skeletons/AutocompleteField.tpl.php
@@ -0,0 +1,39 @@
+
+
+namespace ;
+
+useStatements; ?>
+
+#[AsEntityAutocompleteField]
+class extends AbstractType
+{
+ public function configureOptions(OptionsResolver $resolver)
+ {
+ $resolver->setDefaults([
+ 'class' => entityClassDetails->getShortName(); ?>::class,
+ 'placeholder' => 'Choose a entityClassDetails->getShortName(); ?>',
+ //'choice_label' => 'name',
+
+repositoryClassDetails) { ?>
+ 'query_builder' => function(repositoryClassDetails->getShortName(); ?> $repositoryClassDetails->getShortName()); ?>) {
+ return $repositoryClassDetails->getShortName()); ?>->createQueryBuilder('repositoryClassDetails->getShortName()); ?>');
+ },
+
+ //'security' => 'ROLE_SOMETHING',
+ ]);
+ }
+
+ public function getParent(): string
+ {
+ return ParentEntityAutocompleteType::class;
+ }
+}
diff --git a/src/Autocomplete/src/Resources/translations/AutocompleteBundle.ar.php b/src/Autocomplete/src/Resources/translations/AutocompleteBundle.ar.php
new file mode 100644
index 00000000000..5622e099d79
--- /dev/null
+++ b/src/Autocomplete/src/Resources/translations/AutocompleteBundle.ar.php
@@ -0,0 +1,6 @@
+ 'لم يتم العثور على أي نتائج',
+ // 'No more results' => 'No more results',
+];
diff --git a/src/Autocomplete/src/Resources/translations/AutocompleteBundle.bg.php b/src/Autocomplete/src/Resources/translations/AutocompleteBundle.bg.php
new file mode 100644
index 00000000000..236dec80f33
--- /dev/null
+++ b/src/Autocomplete/src/Resources/translations/AutocompleteBundle.bg.php
@@ -0,0 +1,6 @@
+ 'Няма намерени съвпадения',
+ 'No more results' => 'Няма повече резултати',
+];
diff --git a/src/Autocomplete/src/Resources/translations/AutocompleteBundle.ca.php b/src/Autocomplete/src/Resources/translations/AutocompleteBundle.ca.php
new file mode 100644
index 00000000000..d968a2f8c3b
--- /dev/null
+++ b/src/Autocomplete/src/Resources/translations/AutocompleteBundle.ca.php
@@ -0,0 +1,6 @@
+ 'No s\'han trobat resultats',
+ 'No more results' => 'No hi ha més resultats',
+];
diff --git a/src/Autocomplete/src/Resources/translations/AutocompleteBundle.cs.php b/src/Autocomplete/src/Resources/translations/AutocompleteBundle.cs.php
new file mode 100644
index 00000000000..2869f993cef
--- /dev/null
+++ b/src/Autocomplete/src/Resources/translations/AutocompleteBundle.cs.php
@@ -0,0 +1,6 @@
+ 'Nenalezeny žádné položky',
+ // 'No more results' => 'No more results',
+];
diff --git a/src/Autocomplete/src/Resources/translations/AutocompleteBundle.da.php b/src/Autocomplete/src/Resources/translations/AutocompleteBundle.da.php
new file mode 100644
index 00000000000..66cd87bd013
--- /dev/null
+++ b/src/Autocomplete/src/Resources/translations/AutocompleteBundle.da.php
@@ -0,0 +1,6 @@
+ 'Ingen resultater fundet',
+ // 'No more results' => 'No more results',
+];
diff --git a/src/Autocomplete/src/Resources/translations/AutocompleteBundle.de.php b/src/Autocomplete/src/Resources/translations/AutocompleteBundle.de.php
new file mode 100644
index 00000000000..6396b56ca13
--- /dev/null
+++ b/src/Autocomplete/src/Resources/translations/AutocompleteBundle.de.php
@@ -0,0 +1,6 @@
+ 'Keine Übereinstimmungen gefunden',
+ 'No more results' => 'Keine weiteren Ergebnisse',
+];
diff --git a/src/Autocomplete/src/Resources/translations/AutocompleteBundle.el.php b/src/Autocomplete/src/Resources/translations/AutocompleteBundle.el.php
new file mode 100644
index 00000000000..a8736633616
--- /dev/null
+++ b/src/Autocomplete/src/Resources/translations/AutocompleteBundle.el.php
@@ -0,0 +1,6 @@
+ 'Δεν βρέθηκαν αποτελέσματα',
+ // 'No more results' => 'No more results',
+];
diff --git a/src/Autocomplete/src/Resources/translations/AutocompleteBundle.en.php b/src/Autocomplete/src/Resources/translations/AutocompleteBundle.en.php
new file mode 100644
index 00000000000..af8ebb0bf97
--- /dev/null
+++ b/src/Autocomplete/src/Resources/translations/AutocompleteBundle.en.php
@@ -0,0 +1,6 @@
+ 'No results found',
+ 'No more results' => 'No more results',
+];
diff --git a/src/Autocomplete/src/Resources/translations/AutocompleteBundle.es.php b/src/Autocomplete/src/Resources/translations/AutocompleteBundle.es.php
new file mode 100644
index 00000000000..05383169fdf
--- /dev/null
+++ b/src/Autocomplete/src/Resources/translations/AutocompleteBundle.es.php
@@ -0,0 +1,6 @@
+ 'No se han encontrado resultados',
+ 'No more results' => 'No hay más resultados',
+];
diff --git a/src/Autocomplete/src/Resources/translations/AutocompleteBundle.eu.php b/src/Autocomplete/src/Resources/translations/AutocompleteBundle.eu.php
new file mode 100644
index 00000000000..741eb8f1370
--- /dev/null
+++ b/src/Autocomplete/src/Resources/translations/AutocompleteBundle.eu.php
@@ -0,0 +1,6 @@
+ 'Ez da bat datorrenik aurkitu',
+ // 'No more results' => 'No more results',
+];
diff --git a/src/Autocomplete/src/Resources/translations/AutocompleteBundle.fa.php b/src/Autocomplete/src/Resources/translations/AutocompleteBundle.fa.php
new file mode 100644
index 00000000000..12720f0b21b
--- /dev/null
+++ b/src/Autocomplete/src/Resources/translations/AutocompleteBundle.fa.php
@@ -0,0 +1,6 @@
+ 'هیچ نتیجهای یافت نشد',
+ 'No more results' => 'نتیجه دیگری وجود ندارد',
+];
diff --git a/src/Autocomplete/src/Resources/translations/AutocompleteBundle.fi.php b/src/Autocomplete/src/Resources/translations/AutocompleteBundle.fi.php
new file mode 100644
index 00000000000..690808731d1
--- /dev/null
+++ b/src/Autocomplete/src/Resources/translations/AutocompleteBundle.fi.php
@@ -0,0 +1,6 @@
+ 'Ei tuloksia',
+ // 'No more results' => 'No more results',
+];
diff --git a/src/Autocomplete/src/Resources/translations/AutocompleteBundle.fr.php b/src/Autocomplete/src/Resources/translations/AutocompleteBundle.fr.php
new file mode 100644
index 00000000000..dd8dd8b57a3
--- /dev/null
+++ b/src/Autocomplete/src/Resources/translations/AutocompleteBundle.fr.php
@@ -0,0 +1,6 @@
+ 'Aucun résultat trouvé',
+ 'No more results' => 'Aucun autre résultat trouvé',
+];
diff --git a/src/Autocomplete/src/Resources/translations/AutocompleteBundle.gl.php b/src/Autocomplete/src/Resources/translations/AutocompleteBundle.gl.php
new file mode 100644
index 00000000000..50933d78937
--- /dev/null
+++ b/src/Autocomplete/src/Resources/translations/AutocompleteBundle.gl.php
@@ -0,0 +1,6 @@
+ 'Non se atoparon resultados',
+ // 'No more results' => 'No more results',
+];
diff --git a/src/Autocomplete/src/Resources/translations/AutocompleteBundle.hr.php b/src/Autocomplete/src/Resources/translations/AutocompleteBundle.hr.php
new file mode 100644
index 00000000000..5d7b76e7978
--- /dev/null
+++ b/src/Autocomplete/src/Resources/translations/AutocompleteBundle.hr.php
@@ -0,0 +1,6 @@
+ 'Nema rezultata',
+ // 'No more results' => 'No more results',
+];
diff --git a/src/Autocomplete/src/Resources/translations/AutocompleteBundle.hu.php b/src/Autocomplete/src/Resources/translations/AutocompleteBundle.hu.php
new file mode 100644
index 00000000000..18dad43a8e1
--- /dev/null
+++ b/src/Autocomplete/src/Resources/translations/AutocompleteBundle.hu.php
@@ -0,0 +1,6 @@
+ 'Nincs találat',
+ 'No more results' => 'Nincs több találat',
+];
diff --git a/src/Autocomplete/src/Resources/translations/AutocompleteBundle.id.php b/src/Autocomplete/src/Resources/translations/AutocompleteBundle.id.php
new file mode 100644
index 00000000000..80f0bb9c6f1
--- /dev/null
+++ b/src/Autocomplete/src/Resources/translations/AutocompleteBundle.id.php
@@ -0,0 +1,6 @@
+ 'Tidak ada hasil yang ditemukan',
+ 'No more results' => 'Tidak ada hasil lagi',
+];
diff --git a/src/Autocomplete/src/Resources/translations/AutocompleteBundle.it.php b/src/Autocomplete/src/Resources/translations/AutocompleteBundle.it.php
new file mode 100644
index 00000000000..31569cdcb76
--- /dev/null
+++ b/src/Autocomplete/src/Resources/translations/AutocompleteBundle.it.php
@@ -0,0 +1,6 @@
+ 'Nessun risultato trovato',
+ 'No more results' => 'Non ci sono altri risultati',
+];
diff --git a/src/Autocomplete/src/Resources/translations/AutocompleteBundle.lb.php b/src/Autocomplete/src/Resources/translations/AutocompleteBundle.lb.php
new file mode 100644
index 00000000000..5dfc647ff09
--- /dev/null
+++ b/src/Autocomplete/src/Resources/translations/AutocompleteBundle.lb.php
@@ -0,0 +1,6 @@
+ 'Keng Resultater fonnt',
+ 'No more results' => 'Keng weider Resultater',
+];
diff --git a/src/Autocomplete/src/Resources/translations/AutocompleteBundle.lt.php b/src/Autocomplete/src/Resources/translations/AutocompleteBundle.lt.php
new file mode 100644
index 00000000000..7efcf42e86e
--- /dev/null
+++ b/src/Autocomplete/src/Resources/translations/AutocompleteBundle.lt.php
@@ -0,0 +1,6 @@
+ 'Atitikmenų nerasta',
+ // 'No more results' => 'No more results',
+];
diff --git a/src/Autocomplete/src/Resources/translations/AutocompleteBundle.nl.php b/src/Autocomplete/src/Resources/translations/AutocompleteBundle.nl.php
new file mode 100644
index 00000000000..c6923c725bf
--- /dev/null
+++ b/src/Autocomplete/src/Resources/translations/AutocompleteBundle.nl.php
@@ -0,0 +1,6 @@
+ 'Geen resultaten gevonden…',
+ 'No more results' => 'Niet meer resultaten gevonden…',
+];
diff --git a/src/Autocomplete/src/Resources/translations/AutocompleteBundle.pl.php b/src/Autocomplete/src/Resources/translations/AutocompleteBundle.pl.php
new file mode 100644
index 00000000000..44c84033cbe
--- /dev/null
+++ b/src/Autocomplete/src/Resources/translations/AutocompleteBundle.pl.php
@@ -0,0 +1,6 @@
+ 'Brak wyników',
+ // 'No more results' => 'No more results',
+];
diff --git a/src/Autocomplete/src/Resources/translations/AutocompleteBundle.pt.php b/src/Autocomplete/src/Resources/translations/AutocompleteBundle.pt.php
new file mode 100644
index 00000000000..f5476fba008
--- /dev/null
+++ b/src/Autocomplete/src/Resources/translations/AutocompleteBundle.pt.php
@@ -0,0 +1,6 @@
+ 'Sem resultados',
+ // 'No more results' => 'No more results',
+];
diff --git a/src/Autocomplete/src/Resources/translations/AutocompleteBundle.pt_BR.php b/src/Autocomplete/src/Resources/translations/AutocompleteBundle.pt_BR.php
new file mode 100644
index 00000000000..f968a6b898a
--- /dev/null
+++ b/src/Autocomplete/src/Resources/translations/AutocompleteBundle.pt_BR.php
@@ -0,0 +1,6 @@
+ 'Nenhum resultado encontrado',
+ // 'No more results' => 'No more results',
+];
diff --git a/src/Autocomplete/src/Resources/translations/AutocompleteBundle.ro.php b/src/Autocomplete/src/Resources/translations/AutocompleteBundle.ro.php
new file mode 100644
index 00000000000..9e42c79a833
--- /dev/null
+++ b/src/Autocomplete/src/Resources/translations/AutocompleteBundle.ro.php
@@ -0,0 +1,6 @@
+ 'Nu au fost găsite rezultate',
+ // 'No more results' => 'No more results',
+];
diff --git a/src/Autocomplete/src/Resources/translations/AutocompleteBundle.ru.php b/src/Autocomplete/src/Resources/translations/AutocompleteBundle.ru.php
new file mode 100644
index 00000000000..f568b0972b4
--- /dev/null
+++ b/src/Autocomplete/src/Resources/translations/AutocompleteBundle.ru.php
@@ -0,0 +1,6 @@
+ 'Совпадений не найдено',
+ // 'No more results' => 'No more results',
+];
diff --git a/src/Autocomplete/src/Resources/translations/AutocompleteBundle.sl.php b/src/Autocomplete/src/Resources/translations/AutocompleteBundle.sl.php
new file mode 100644
index 00000000000..ecf851b8ecc
--- /dev/null
+++ b/src/Autocomplete/src/Resources/translations/AutocompleteBundle.sl.php
@@ -0,0 +1,6 @@
+ 'Ni zadetkov',
+ // 'No more results' => 'No more results',
+];
diff --git a/src/Autocomplete/src/Resources/translations/AutocompleteBundle.sr_RS.php b/src/Autocomplete/src/Resources/translations/AutocompleteBundle.sr_RS.php
new file mode 100644
index 00000000000..49140df770b
--- /dev/null
+++ b/src/Autocomplete/src/Resources/translations/AutocompleteBundle.sr_RS.php
@@ -0,0 +1,6 @@
+ 'Nema rezultata',
+ 'No more results' => 'Nema više rezultata',
+];
diff --git a/src/Autocomplete/src/Resources/translations/AutocompleteBundle.sv.php b/src/Autocomplete/src/Resources/translations/AutocompleteBundle.sv.php
new file mode 100644
index 00000000000..5bc128e13d9
--- /dev/null
+++ b/src/Autocomplete/src/Resources/translations/AutocompleteBundle.sv.php
@@ -0,0 +1,6 @@
+ 'Inga träffar',
+ // 'No more results' => 'No more results',
+];
diff --git a/src/Autocomplete/src/Resources/translations/AutocompleteBundle.tr.php b/src/Autocomplete/src/Resources/translations/AutocompleteBundle.tr.php
new file mode 100644
index 00000000000..694964a3041
--- /dev/null
+++ b/src/Autocomplete/src/Resources/translations/AutocompleteBundle.tr.php
@@ -0,0 +1,6 @@
+ 'Sonuç bulunamadı',
+ // 'No more results' => 'No more results',
+];
diff --git a/src/Autocomplete/src/Resources/translations/AutocompleteBundle.uk.php b/src/Autocomplete/src/Resources/translations/AutocompleteBundle.uk.php
new file mode 100644
index 00000000000..198bda4057c
--- /dev/null
+++ b/src/Autocomplete/src/Resources/translations/AutocompleteBundle.uk.php
@@ -0,0 +1,6 @@
+ 'Нічого не знайдено',
+ // 'No more results' => 'No more results',
+];
diff --git a/src/Autocomplete/src/Resources/translations/AutocompleteBundle.zh_CN.php b/src/Autocomplete/src/Resources/translations/AutocompleteBundle.zh_CN.php
new file mode 100644
index 00000000000..b57b9938cb1
--- /dev/null
+++ b/src/Autocomplete/src/Resources/translations/AutocompleteBundle.zh_CN.php
@@ -0,0 +1,6 @@
+ '未找到结果',
+ // 'No more results' => 'No more results',
+];
diff --git a/src/Autocomplete/src/Resources/views/autocomplete_form_theme.html.twig b/src/Autocomplete/src/Resources/views/autocomplete_form_theme.html.twig
new file mode 100644
index 00000000000..d62f4470062
--- /dev/null
+++ b/src/Autocomplete/src/Resources/views/autocomplete_form_theme.html.twig
@@ -0,0 +1,9 @@
+{# EasyAdminAutocomplete form type #}
+{% block ux_entity_autocomplete_widget %}
+ {{ form_widget(form.autocomplete, { attr: attr|merge({ required: required }) }) }}
+{% endblock ux_entity_autocomplete_widget %}
+
+{% block ux_entity_autocomplete_label %}
+ {% set id = form.autocomplete.vars.id %}
+ {{ block('form_label') }}
+{% endblock ux_entity_autocomplete_label %}
diff --git a/src/Autocomplete/tests/Fixtures/Autocompleter/CustomProductAutocompleter.php b/src/Autocomplete/tests/Fixtures/Autocompleter/CustomProductAutocompleter.php
new file mode 100644
index 00000000000..37ccb0cf85d
--- /dev/null
+++ b/src/Autocomplete/tests/Fixtures/Autocompleter/CustomProductAutocompleter.php
@@ -0,0 +1,55 @@
+createQueryBuilder('p')
+ ->andWhere('p.isEnabled = :enabled')
+ ->setParameter('enabled', true);
+ }
+
+ public function getLabel(object $entity): string
+ {
+ return $entity->getName();
+ }
+
+ public function getValue(object $entity): mixed
+ {
+ return $entity->getId();
+ }
+
+ public function getSearchableFields(): ?array
+ {
+ return ['name', 'description'];
+ }
+
+ public function isGranted(Security $security): bool
+ {
+ if ($this->requestStack->getCurrentRequest()?->query->get('enforce_test_security')) {
+ return $security->isGranted('ROLE_USER');
+ }
+
+ return true;
+ }
+}
diff --git a/src/Autocomplete/tests/Fixtures/Entity/Category.php b/src/Autocomplete/tests/Fixtures/Entity/Category.php
new file mode 100644
index 00000000000..3cc6f9b835d
--- /dev/null
+++ b/src/Autocomplete/tests/Fixtures/Entity/Category.php
@@ -0,0 +1,75 @@
+products = new ArrayCollection();
+ }
+
+ public function getId(): ?int
+ {
+ return $this->id;
+ }
+
+ public function getName(): ?string
+ {
+ return $this->name;
+ }
+
+ public function setName(string $name): self
+ {
+ $this->name = $name;
+
+ return $this;
+ }
+
+ /**
+ * @return Collection
+ */
+ public function getProducts(): Collection
+ {
+ return $this->products;
+ }
+
+ public function addProduct(Product $product): self
+ {
+ if (!$this->products->contains($product)) {
+ $this->products[] = $product;
+ $product->setCategory($this);
+ }
+
+ return $this;
+ }
+
+ public function removeProduct(Product $product): self
+ {
+ if ($this->products->removeElement($product)) {
+ // set the owning side to null (unless already changed)
+ if ($product->getCategory() === $this) {
+ $product->setCategory(null);
+ }
+ }
+
+ return $this;
+ }
+}
diff --git a/src/Autocomplete/tests/Fixtures/Entity/Product.php b/src/Autocomplete/tests/Fixtures/Entity/Product.php
new file mode 100644
index 00000000000..966ae0ece5e
--- /dev/null
+++ b/src/Autocomplete/tests/Fixtures/Entity/Product.php
@@ -0,0 +1,97 @@
+id;
+ }
+
+ public function getName(): ?string
+ {
+ return $this->name;
+ }
+
+ public function setName(string $name): self
+ {
+ $this->name = $name;
+
+ return $this;
+ }
+
+ public function getDescription(): ?string
+ {
+ return $this->description;
+ }
+
+ public function setDescription(string $description): self
+ {
+ $this->description = $description;
+
+ return $this;
+ }
+
+ public function getPrice(): ?int
+ {
+ return $this->price;
+ }
+
+ public function setPrice(int $price): self
+ {
+ $this->price = $price;
+
+ return $this;
+ }
+
+ public function isEnabled(): bool
+ {
+ return $this->isEnabled;
+ }
+
+ public function setIsEnabled(bool $isEnabled): self
+ {
+ $this->isEnabled = $isEnabled;
+
+ return $this;
+ }
+
+ public function getCategory(): ?Category
+ {
+ return $this->category;
+ }
+
+ public function setCategory(?Category $category): self
+ {
+ $this->category = $category;
+
+ return $this;
+ }
+}
diff --git a/src/Autocomplete/tests/Fixtures/Factory/CategoryFactory.php b/src/Autocomplete/tests/Fixtures/Factory/CategoryFactory.php
new file mode 100644
index 00000000000..e24abe0905d
--- /dev/null
+++ b/src/Autocomplete/tests/Fixtures/Factory/CategoryFactory.php
@@ -0,0 +1,47 @@
+
+ *
+ * @method static Category|Proxy createOne(array $attributes = [])
+ * @method static Category[]|Proxy[] createMany(int $number, array|callable $attributes = [])
+ * @method static Category|Proxy find(object|array|mixed $criteria)
+ * @method static Category|Proxy findOrCreate(array $attributes)
+ * @method static Category|Proxy first(string $sortedField = 'id')
+ * @method static Category|Proxy last(string $sortedField = 'id')
+ * @method static Category|Proxy random(array $attributes = [])
+ * @method static Category|Proxy randomOrCreate(array $attributes = []))
+ * @method static Category[]|Proxy[] all()
+ * @method static Category[]|Proxy[] findBy(array $attributes)
+ * @method static Category[]|Proxy[] randomSet(int $number, array $attributes = []))
+ * @method static Category[]|Proxy[] randomRange(int $min, int $max, array $attributes = []))
+ * @method static EntityRepository|RepositoryProxy repository()
+ * @method Category|Proxy create(array|callable $attributes = [])
+ */
+final class CategoryFactory extends ModelFactory
+{
+ protected function getDefaults(): array
+ {
+ return [
+ 'name' => self::faker()->text(),
+ ];
+ }
+
+ protected function initialize(): self
+ {
+ return $this;
+ }
+
+ protected static function getClass(): string
+ {
+ return Category::class;
+ }
+}
diff --git a/src/Autocomplete/tests/Fixtures/Factory/ProductFactory.php b/src/Autocomplete/tests/Fixtures/Factory/ProductFactory.php
new file mode 100644
index 00000000000..4b99ac1f02f
--- /dev/null
+++ b/src/Autocomplete/tests/Fixtures/Factory/ProductFactory.php
@@ -0,0 +1,55 @@
+
+ *
+ * @method static Product|Proxy createOne(array $attributes = [])
+ * @method static Product[]|Proxy[] createMany(int $number, array|callable $attributes = [])
+ * @method static Product|Proxy find(object|array|mixed $criteria)
+ * @method static Product|Proxy findOrCreate(array $attributes)
+ * @method static Product|Proxy first(string $sortedField = 'id')
+ * @method static Product|Proxy last(string $sortedField = 'id')
+ * @method static Product|Proxy random(array $attributes = [])
+ * @method static Product|Proxy randomOrCreate(array $attributes = []))
+ * @method static Product[]|Proxy[] all()
+ * @method static Product[]|Proxy[] findBy(array $attributes)
+ * @method static Product[]|Proxy[] randomSet(int $number, array $attributes = []))
+ * @method static Product[]|Proxy[] randomRange(int $min, int $max, array $attributes = []))
+ * @method static EntityRepository|RepositoryProxy repository()
+ * @method Product|Proxy create(array|callable $attributes = [])
+ */
+final class ProductFactory extends ModelFactory
+{
+ protected function getDefaults(): array
+ {
+ return [
+ 'name' => self::faker()->text(),
+ 'category' => CategoryFactory::new(),
+ 'price' => self::faker()->numberBetween(1000, 9999),
+ 'description' => self::faker()->paragraph(),
+ ];
+ }
+
+ protected function initialize(): self
+ {
+ return $this;
+ }
+
+ public function disable(): self
+ {
+ return $this->addState(['isEnabled' => false]);
+ }
+
+ protected static function getClass(): string
+ {
+ return Product::class;
+ }
+}
diff --git a/src/Autocomplete/tests/Fixtures/Form/CategoryAutocompleteType.php b/src/Autocomplete/tests/Fixtures/Form/CategoryAutocompleteType.php
new file mode 100644
index 00000000000..3a2eadaa1a4
--- /dev/null
+++ b/src/Autocomplete/tests/Fixtures/Form/CategoryAutocompleteType.php
@@ -0,0 +1,48 @@
+setDefaults([
+ 'class' => Category::class,
+ 'choice_label' => function(Category $category) {
+ return ''.$category->getName().' ';
+ },
+ 'query_builder' => function(EntityRepository $repository) {
+ return $repository->createQueryBuilder('category')
+ ->andWhere('category.name LIKE :search')
+ ->setParameter('search', '%foo%');
+ },
+ 'security' => function(Security $security) {
+ if ($this->requestStack->getCurrentRequest()?->query->get('enforce_test_security')) {
+ return $security->isGranted('ROLE_USER');
+ }
+
+ return true;
+ },
+ 'placeholder' => 'What should we eat?',
+ ]);
+ }
+
+ public function getParent(): string
+ {
+ return ParentEntityAutocompleteType::class;
+ }
+}
diff --git a/src/Autocomplete/tests/Fixtures/Form/ProductType.php b/src/Autocomplete/tests/Fixtures/Form/ProductType.php
new file mode 100644
index 00000000000..9261087fd8f
--- /dev/null
+++ b/src/Autocomplete/tests/Fixtures/Form/ProductType.php
@@ -0,0 +1,49 @@
+add('category', CategoryAutocompleteType::class)
+ ->add('portionSize', ChoiceType::class, [
+ 'choices' => [
+ 'extra small 🥨 ' => 'xs',
+ 'small' => 's',
+ 'medium' => 'm',
+ 'large' => 'l',
+ 'extra large' => 'xl',
+ 'all you can eat' => '∞',
+ ],
+ 'options_as_html' => true,
+ 'autocomplete' => true,
+ 'mapped' => false,
+ ])
+ ->add('tags', TextType::class, [
+ 'mapped' => false,
+ 'autocomplete' => true,
+ 'tom_select_options' => [
+ 'create' => true,
+ 'createOnBlur' => true,
+ ],
+ ])
+ ;
+ }
+
+ public function configureOptions(OptionsResolver $resolver): void
+ {
+ $resolver->setDefaults([
+ 'data_class' => Product::class,
+ 'csrf_protection' => false,
+ ]);
+ }
+}
diff --git a/src/Autocomplete/tests/Fixtures/Kernel.php b/src/Autocomplete/tests/Fixtures/Kernel.php
new file mode 100644
index 00000000000..82e76b80662
--- /dev/null
+++ b/src/Autocomplete/tests/Fixtures/Kernel.php
@@ -0,0 +1,148 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\Autocomplete\Tests\Fixtures;
+
+use Doctrine\Bundle\DoctrineBundle\DoctrineBundle;
+use Psr\Log\NullLogger;
+use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
+use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
+use Symfony\Bundle\MakerBundle\MakerBundle;
+use Symfony\Bundle\SecurityBundle\SecurityBundle;
+use Symfony\Bundle\TwigBundle\TwigBundle;
+use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
+use Symfony\Component\Form\FormFactoryInterface;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\HttpKernel\Kernel as BaseKernel;
+use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator;
+use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
+use Symfony\UX\Autocomplete\AutocompleteBundle;
+use Symfony\UX\Autocomplete\DependencyInjection\AutocompleteFormTypePass;
+use Symfony\UX\Autocomplete\Tests\Fixtures\Autocompleter\CustomProductAutocompleter;
+use Symfony\UX\Autocomplete\Tests\Fixtures\Form\ProductType;
+use Twig\Environment;
+
+final class Kernel extends BaseKernel
+{
+ use MicroKernelTrait;
+
+ private bool $enableForms = true;
+
+ public function disableForms(): void
+ {
+ $this->enableForms = false;
+ }
+
+ public function testForm(FormFactoryInterface $formFactory, Environment $twig, Request $request): Response
+ {
+ $form = $formFactory->create(ProductType::class);
+ $form->handleRequest($request);
+
+ return new Response($twig->render('form.html.twig', [
+ 'form' => $form->createView()
+ ]));
+ }
+
+ public function registerBundles(): iterable
+ {
+ yield new FrameworkBundle();
+ yield new TwigBundle();
+ yield new DoctrineBundle();
+ yield new AutocompleteBundle();
+ yield new SecurityBundle();
+ yield new MakerBundle();
+ }
+
+ protected function configureContainer(ContainerConfigurator $c): void
+ {
+ $c->extension('framework', [
+ 'secret' => 'S3CRET',
+ 'http_method_override' => false,
+ 'test' => true,
+ 'router' => ['utf8' => true],
+ 'secrets' => false,
+ 'session' => ['storage_factory_id' => 'session.storage.factory.mock_file'],
+ 'form' => ['enabled' => $this->enableForms],
+ ]);
+
+ $c->extension('twig', [
+ 'default_path' => '%kernel.project_dir%/tests/Fixtures/templates',
+ ]);
+
+ $c->extension('doctrine', [
+ 'dbal' => ['url' => '%env(resolve:DATABASE_URL)%'],
+ 'orm' => [
+ 'auto_generate_proxy_classes' => true,
+ 'auto_mapping' => true,
+ 'mappings' => [
+ 'Test' => [
+ 'is_bundle' => false,
+ 'dir' => '%kernel.project_dir%/tests/Fixtures/Entity',
+ 'prefix' => 'Symfony\UX\Autocomplete\Tests\Fixtures\Entity',
+ 'alias' => 'Test',
+ ],
+ ],
+ ],
+ ]);
+
+ $c->extension('security', [
+ 'password_hashers' => [
+ PasswordAuthenticatedUserInterface::class => 'plaintext'
+ ],
+ 'providers' => [
+ 'users_in_memory' => [
+ 'memory' => [
+ 'users' => [
+ 'mr_autocompleter' => ['password' => 'symfonypass', 'roles' => ['ROLE_USER']]
+ ],
+ ],
+ ]
+ ],
+ 'firewalls' => [
+ 'main' => [
+ 'http_basic' => true,
+ ],
+ ],
+ ]);
+
+ $services = $c->services();
+ $services
+ ->defaults()
+ ->autowire()
+ ->autoconfigure()
+ // disable logging errors to the console
+ ->set('logger', NullLogger::class)
+ ->load(__NAMESPACE__.'\\', __DIR__)
+ ->exclude(['Kernel.php'])
+ ;
+
+ $services->set(CustomProductAutocompleter::class)
+ ->public()
+ ->tag(AutocompleteFormTypePass::ENTITY_AUTOCOMPLETER_TAG, [
+ 'alias' => 'custom_product'
+ ]);
+
+ $services->alias('public.results_executor', 'ux.autocomplete.results_executor')
+ ->public();
+
+ $services->alias('public.ux.autocomplete.make_autocomplete_field', 'ux.autocomplete.make_autocomplete_field')
+ ->public();
+ }
+
+ protected function configureRoutes(RoutingConfigurator $routes): void
+ {
+ $routes->import('@AutocompleteBundle/Resources/routes.php')
+ ->prefix('/test/autocomplete');
+
+ $routes->add('test_form', '/test-form')->controller('kernel::testForm');
+ }
+}
diff --git a/src/Autocomplete/tests/Fixtures/templates/form.html.twig b/src/Autocomplete/tests/Fixtures/templates/form.html.twig
new file mode 100644
index 00000000000..5159f35e65a
--- /dev/null
+++ b/src/Autocomplete/tests/Fixtures/templates/form.html.twig
@@ -0,0 +1,5 @@
+{{ form_start(form) }}
+ {{ form_widget(form) }}
+
+ Save
+{{ form_end(form) }}
diff --git a/src/Autocomplete/tests/Functional/AutocompleteFormRenderingTest.php b/src/Autocomplete/tests/Functional/AutocompleteFormRenderingTest.php
new file mode 100644
index 00000000000..f03e749537b
--- /dev/null
+++ b/src/Autocomplete/tests/Functional/AutocompleteFormRenderingTest.php
@@ -0,0 +1,63 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\Autocomplete\Tests\Functional;
+
+use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
+use Symfony\UX\Autocomplete\Tests\Fixtures\Factory\CategoryFactory;
+use Zenstruck\Browser\Test\HasBrowser;
+use Zenstruck\Foundry\Test\Factories;
+use Zenstruck\Foundry\Test\ResetDatabase;
+
+// tests CategoryAutocompleteType
+class AutocompleteFormRenderingTest extends KernelTestCase
+{
+ use Factories;
+ use HasBrowser;
+ use ResetDatabase;
+
+ public function testFieldsRenderWithStimulusController()
+ {
+ $this->browser()
+ ->throwExceptions()
+ ->get('/test-form')
+ ->assertElementAttributeContains('#product_category_autocomplete', 'data-controller', 'symfony--ux-autocomplete--autocomplete')
+ ->assertElementAttributeContains('#product_category_autocomplete', 'data-symfony--ux-autocomplete--autocomplete-url-value', '/test/autocomplete/category_autocomplete_type')
+
+ ->assertElementAttributeContains('#product_portionSize', 'data-controller', 'symfony--ux-autocomplete--autocomplete')
+ ->assertElementAttributeContains('#product_tags', 'data-controller', 'symfony--ux-autocomplete--autocomplete')
+ ->assertElementAttributeContains('#product_tags', 'data-symfony--ux-autocomplete--autocomplete-tom-select-options-value', 'createOnBlur')
+ ;
+ }
+
+ public function testCategoryFieldSubmitsCorrectly()
+ {
+ $firstCat = CategoryFactory::createOne(['name' => 'First cat']);
+ CategoryFactory::createOne(['name' => 'in space']);
+ CategoryFactory::createOne(['name' => 'ate pizza']);
+
+ $this->browser()
+ ->throwExceptions()
+ ->get('/test-form')
+ // the field renders empty (but the placeholder is there)
+ ->assertElementCount('#product_category_autocomplete option', 1)
+ ->assertNotContains('First cat')
+ ->post('/test-form', [
+ 'body' => [
+ 'product' => ['category' => ['autocomplete' => $firstCat->getId()]],
+ ],
+ ])
+ // the one option + placeholder now shows up
+ ->assertElementCount('#product_category_autocomplete option', 2)
+ ->assertContains('First cat')
+ ;
+ }
+}
diff --git a/src/Autocomplete/tests/Functional/CustomAutocompleterTest.php b/src/Autocomplete/tests/Functional/CustomAutocompleterTest.php
new file mode 100644
index 00000000000..34555f80bb5
--- /dev/null
+++ b/src/Autocomplete/tests/Functional/CustomAutocompleterTest.php
@@ -0,0 +1,107 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\Autocomplete\Tests\Functional;
+
+use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
+use Symfony\Component\Security\Core\User\InMemoryUser;
+use Symfony\UX\Autocomplete\Tests\Fixtures\Factory\ProductFactory;
+use Zenstruck\Browser\Test\HasBrowser;
+use Zenstruck\Foundry\Test\Factories;
+use Zenstruck\Foundry\Test\ResetDatabase;
+
+// tests CustomProductAutocompleter
+class CustomAutocompleterTest extends KernelTestCase
+{
+ use Factories;
+ use HasBrowser;
+ use ResetDatabase;
+
+ public function testItReturnsBasicResults(): void
+ {
+ $product = ProductFactory::createOne(['name' => 'foo']);
+ ProductFactory::createOne(['name' => 'bar']);
+ ProductFactory::createOne(['name' => 'foo and bar']);
+
+ $this->browser()
+ ->throwExceptions()
+ ->get('/test/autocomplete/custom_product')
+ ->assertSuccessful()
+ ->assertJsonMatches('length(results)', 3)
+ ->assertJsonMatches('results[0].value', $product->getId())
+ ->assertJsonMatches('results[0].text', 'foo')
+ ->get('/test/autocomplete/custom_product?query=bar')
+ ->assertJsonMatches('length(results)', 2)
+ ;
+ }
+
+ public function testItUsesTheCustomQuery(): void
+ {
+ ProductFactory::createOne(['name' => 'foo']);
+ ProductFactory::new(['name' => 'foo and bar'])
+ ->disable()
+ ->create();
+
+ $this->browser()
+ ->throwExceptions()
+ ->get('/test/autocomplete/custom_product?query=foo')
+ ->assertSuccessful()
+ ->assertJsonMatches('length(results)', 1)
+ ->assertJsonMatches('results[0].text', 'foo')
+ ;
+ }
+
+ public function testItOnlySearchedOnSearchableFields(): void
+ {
+ ProductFactory::createOne(['name' => 'foo', 'price' => 50]);
+ ProductFactory::createOne(['name' => 'bar', 'description' => 'foo 50', 'price' => 55]);
+
+ $this->browser()
+ ->throwExceptions()
+ // search on name or description
+ ->get('/test/autocomplete/custom_product?query=foo')
+ ->assertSuccessful()
+ ->assertJsonMatches('length(results)', 2)
+ ->get('/test/autocomplete/custom_product?query=50')
+ // price should not be searched
+ ->assertJsonMatches('length(results)', 1)
+ ->assertJsonMatches('results[0].text', 'bar')
+ ;
+ }
+
+ public function testItEnforcesSecurity(): void
+ {
+ ProductFactory::createMany(3);
+
+ $this->browser()
+ // enforce_test_security is a custom flag used in CustomProductAutocomplete
+ ->get('/test/autocomplete/custom_product?enforce_test_security=1')
+ ->assertStatus(401)
+ ->actingAs(new InMemoryUser('mr_autocompleter', null, ['ROLE_USER']))
+ ->get('/test/autocomplete/custom_product?enforce_test_security=1', [
+ 'server' => [
+ 'PHP_AUTH_USER' => 'mr_autocompleter',
+ 'PHP_AUTH_PW' => 'symfonypass',
+ ],
+ ])
+ ->assertSuccessful()
+ ->assertJsonMatches('length(results)', 3)
+ ;
+ }
+
+ public function testItReturns404OnBadAlias(): void
+ {
+ $this->browser()
+ ->get('/test/autocomplete/not_real')
+ ->assertStatus(404)
+ ;
+ }
+}
diff --git a/src/Autocomplete/tests/Functional/FieldAutocompleterTest.php b/src/Autocomplete/tests/Functional/FieldAutocompleterTest.php
new file mode 100644
index 00000000000..1d273da0ebd
--- /dev/null
+++ b/src/Autocomplete/tests/Functional/FieldAutocompleterTest.php
@@ -0,0 +1,81 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\Autocomplete\Tests\Functional;
+
+use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
+use Symfony\Component\Security\Core\User\InMemoryUser;
+use Symfony\UX\Autocomplete\Tests\Fixtures\Factory\CategoryFactory;
+use Zenstruck\Browser\Test\HasBrowser;
+use Zenstruck\Foundry\Test\Factories;
+use Zenstruck\Foundry\Test\ResetDatabase;
+
+// tests CategoryAutocompleteType
+class FieldAutocompleterTest extends KernelTestCase
+{
+ use Factories;
+ use HasBrowser;
+ use ResetDatabase;
+
+ public function testItReturnsBasicResults(): void
+ {
+ $category = CategoryFactory::createOne(['name' => 'foo and baz']);
+ CategoryFactory::createOne(['name' => 'foo and bar']);
+
+ $this->browser()
+ ->throwExceptions()
+ ->get('/test/autocomplete/category_autocomplete_type')
+ ->assertSuccessful()
+ ->assertJsonMatches('length(results)', 2)
+ ->assertJsonMatches('results[0].value', (string) $category->getId())
+ ->assertJsonMatches('results[0].text', 'foo and baz ')
+ ->get('/test/autocomplete/category_autocomplete_type?query=bar')
+ ->assertJsonMatches('length(results)', 1)
+ ;
+ }
+
+ public function testItUsesTheCustomQuery(): void
+ {
+ CategoryFactory::createOne(['name' => 'foo and bar']);
+ CategoryFactory::createOne(['name' => 'baz and bar']);
+
+ $this->browser()
+ ->throwExceptions()
+ // query already ONLY returns items matching "foo"
+ ->get('/test/autocomplete/category_autocomplete_type?query=bar')
+ ->assertSuccessful()
+ ->assertJsonMatches('length(results)', 1)
+ ->assertJsonMatches('results[0].text', 'foo and bar ')
+ ;
+ }
+
+ public function testItEnforcesSecurity(): void
+ {
+ CategoryFactory::createMany(3, [
+ 'name' => 'foo so that it matches custom query',
+ ]);
+
+ $this->browser()
+ // enforce_test_security is a custom flag used in FieldAutocompleterTest
+ ->get('/test/autocomplete/category_autocomplete_type?enforce_test_security=1')
+ ->assertStatus(401)
+ ->actingAs(new InMemoryUser('mr_autocompleter', null, ['ROLE_USER']))
+ ->get('/test/autocomplete/category_autocomplete_type?enforce_test_security=1', [
+ 'server' => [
+ 'PHP_AUTH_USER' => 'mr_autocompleter',
+ 'PHP_AUTH_PW' => 'symfonypass',
+ ],
+ ])
+ ->assertSuccessful()
+ ->assertJsonMatches('length(results)', 3)
+ ;
+ }
+}
diff --git a/src/Autocomplete/tests/Integration/Doctrine/EntityMetadataFactoryTest.php b/src/Autocomplete/tests/Integration/Doctrine/EntityMetadataFactoryTest.php
new file mode 100644
index 00000000000..c9a912f23eb
--- /dev/null
+++ b/src/Autocomplete/tests/Integration/Doctrine/EntityMetadataFactoryTest.php
@@ -0,0 +1,28 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\Autocomplete\Tests\Integration\Doctrine;
+
+use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
+use Symfony\UX\Autocomplete\Doctrine\EntityMetadata;
+use Symfony\UX\Autocomplete\Doctrine\EntityMetadataFactory;
+use Symfony\UX\Autocomplete\Tests\Fixtures\Entity\Product;
+
+class EntityMetadataFactoryTest extends KernelTestCase
+{
+ public function testItSuccessfullyCreatesMetadata(): void
+ {
+ /** @var EntityMetadataFactory $factory */
+ $factory = self::getContainer()->get('ux.autocomplete.entity_metadata_factory');
+ $metadata = $factory->create(Product::class);
+ $this->assertInstanceOf(EntityMetadata::class, $metadata);
+ }
+}
diff --git a/src/Autocomplete/tests/Integration/Doctrine/EntityMetadataTest.php b/src/Autocomplete/tests/Integration/Doctrine/EntityMetadataTest.php
new file mode 100644
index 00000000000..1dd30667edd
--- /dev/null
+++ b/src/Autocomplete/tests/Integration/Doctrine/EntityMetadataTest.php
@@ -0,0 +1,85 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\Autocomplete\Tests\Integration\Doctrine;
+
+use Doctrine\DBAL\Types\Types;
+use Doctrine\ORM\Mapping\ClassMetadataInfo;
+use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
+use Symfony\UX\Autocomplete\Doctrine\EntityMetadata;
+use Symfony\UX\Autocomplete\Doctrine\EntityMetadataFactory;
+use Symfony\UX\Autocomplete\Tests\Fixtures\Entity\Product;
+use Symfony\UX\Autocomplete\Tests\Fixtures\Factory\ProductFactory;
+use Zenstruck\Foundry\Test\Factories;
+use Zenstruck\Foundry\Test\ResetDatabase;
+
+class EntityMetadataTest extends KernelTestCase
+{
+ use Factories;
+ use ResetDatabase;
+
+ public function testGetAllPropertyNames(): void
+ {
+ $this->assertSame(
+ ['id', 'name', 'description', 'price', 'isEnabled'],
+ $this->getMetadata()->getAllPropertyNames()
+ );
+ }
+
+ public function testIsAssociation(): void
+ {
+ $metadata = $this->getMetadata();
+ $this->assertFalse($metadata->isAssociation('name'));
+ $this->assertTrue($metadata->isAssociation('category'));
+ }
+
+ public function testGetIdValue(): void
+ {
+ $product = ProductFactory::createOne();
+ $this->assertEquals($product->getId(), $this->getMetadata()->getIdValue($product->object()));
+ }
+
+ public function testGetPropertyDataType(): void
+ {
+ $metadata = $this->getMetadata();
+ $this->assertSame(Types::STRING, $metadata->getPropertyDataType('name'));
+ $this->assertEquals(ClassMetadataInfo::MANY_TO_ONE, $metadata->getPropertyDataType('category'));
+ }
+
+ public function testGetPropertyMetadata(): void
+ {
+ $metadata = $this->getMetadata();
+ $this->assertSame([
+ 'fieldName' => 'name',
+ 'type' => 'string',
+ 'scale' => null,
+ 'length' => null,
+ 'unique' => false,
+ 'nullable' => false,
+ 'precision' => null,
+ 'columnName' => 'name',
+ ], $metadata->getPropertyMetadata('name'));
+ }
+
+ public function testIsEmbeddedClassProperty(): void
+ {
+ // TODO
+ $this->markTestIncomplete();
+ }
+
+ private function getMetadata(): EntityMetadata
+ {
+ /** @var EntityMetadataFactory $factory */
+ $factory = self::getContainer()->get('ux.autocomplete.entity_metadata_factory');
+
+ return $factory->create(Product::class);
+ }
+}
diff --git a/src/Autocomplete/tests/Integration/Doctrine/EntitySearchUtilTest.php b/src/Autocomplete/tests/Integration/Doctrine/EntitySearchUtilTest.php
new file mode 100644
index 00000000000..62a29001887
--- /dev/null
+++ b/src/Autocomplete/tests/Integration/Doctrine/EntitySearchUtilTest.php
@@ -0,0 +1,83 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\Autocomplete\Tests\Integration\Doctrine;
+
+use Doctrine\ORM\EntityRepository;
+use Doctrine\Persistence\ManagerRegistry;
+use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
+use Symfony\UX\Autocomplete\Doctrine\EntitySearchUtil;
+use Symfony\UX\Autocomplete\Tests\Fixtures\Entity\Product;
+use Symfony\UX\Autocomplete\Tests\Fixtures\Factory\CategoryFactory;
+use Symfony\UX\Autocomplete\Tests\Fixtures\Factory\ProductFactory;
+use Zenstruck\Foundry\Test\Factories;
+use Zenstruck\Foundry\Test\ResetDatabase;
+
+class EntitySearchUtilTest extends KernelTestCase
+{
+ use Factories;
+ use ResetDatabase;
+
+ public function testItCreatesBasicStringSearchQuery(): void
+ {
+ $prod1 = ProductFactory::createOne(['name' => 'bar prod1']);
+ $prod2 = ProductFactory::createOne(['name' => 'foo prod2']);
+ ProductFactory::createOne(['name' => 'baz thing3']);
+ $prod4 = ProductFactory::createOne(['description' => 'all about prod 4']);
+
+ $results = $this->callAddSearchClass('prod');
+ $this->assertSame([$prod1->object(), $prod2->object(), $prod4->object()], $results);
+ }
+
+ public function testItSearchesOnCorrectFields(): void
+ {
+ $prod1 = ProductFactory::createOne(['name' => 'bar prod1']);
+ ProductFactory::createOne(['description' => 'foo prod2']);
+
+ $results = $this->callAddSearchClass('prod', ['name']);
+ $this->assertSame([$prod1->object()], $results);
+ }
+
+ public function testItCanSearchOnRelationFields(): void
+ {
+ $category1 = CategoryFactory::createOne(['name' => 'foods']);
+ $category2 = CategoryFactory::createOne(['name' => 'toys']);
+ $prod1 = ProductFactory::createOne(['name' => 'pizza', 'category' => $category1]);
+ $prod2 = ProductFactory::createOne(['name' => 'toy food', 'category' => $category2]);
+ ProductFactory::createOne(['name' => 'puzzle', 'category' => $category2]);
+
+ $results = $this->callAddSearchClass('food', ['name', 'category.name']);
+ $this->assertSame([$prod1->object(), $prod2->object()], $results);
+ }
+
+ /**
+ * @return array
+ */
+ private function callAddSearchClass(string $search, array $searchableProperties = null): array
+ {
+ /** @var ManagerRegistry $registry */
+ $registry = self::getContainer()->get('doctrine');
+ /** @var EntityRepository $repository */
+ $repository = $registry->getRepository(Product::class);
+ $queryBuilder = $repository->createQueryBuilder('prod');
+
+ /** @var EntitySearchUtil $searchUtil */
+ $searchUtil = self::getContainer()->get('ux.autocomplete.entity_search_util');
+ $searchUtil->addSearchClause(
+ $queryBuilder,
+ $search,
+ Product::class,
+ $searchableProperties
+ );
+
+ return $queryBuilder->getQuery()->execute();
+ }
+}
diff --git a/src/Autocomplete/tests/Integration/WiringTest.php b/src/Autocomplete/tests/Integration/WiringTest.php
new file mode 100644
index 00000000000..c9421d6c04e
--- /dev/null
+++ b/src/Autocomplete/tests/Integration/WiringTest.php
@@ -0,0 +1,49 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\Autocomplete\Tests\Integration;
+
+use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
+use Symfony\Component\HttpKernel\KernelInterface;
+use Symfony\UX\Autocomplete\AutocompleteResultsExecutor;
+use Symfony\UX\Autocomplete\Tests\Fixtures\Autocompleter\CustomProductAutocompleter;
+use Symfony\UX\Autocomplete\Tests\Fixtures\Factory\ProductFactory;
+use Symfony\UX\Autocomplete\Tests\Fixtures\Kernel;
+use Zenstruck\Foundry\Test\Factories;
+use Zenstruck\Foundry\Test\ResetDatabase;
+
+class WiringTest extends KernelTestCase
+{
+ use Factories;
+ use ResetDatabase;
+
+ protected static function createKernel(array $options = []): KernelInterface
+ {
+ $kernel = new Kernel('test', true);
+ $kernel->disableForms();
+
+ return $kernel;
+ }
+
+ public function testWiringWithoutForm(): void
+ {
+ $kernel = new Kernel('test', true);
+ $kernel->disableForms();
+ $kernel->boot();
+
+ ProductFactory::createMany(3);
+
+ /** @var AutocompleteResultsExecutor $executor */
+ $executor = $kernel->getContainer()->get('public.results_executor');
+ $autocompleter = $kernel->getContainer()->get(CustomProductAutocompleter::class);
+ $this->assertCount(3, $executor->fetchResults($autocompleter, ''));
+ }
+}
diff --git a/src/Autocomplete/tests/Unit/AutocompleteResultsExecutorTest.php b/src/Autocomplete/tests/Unit/AutocompleteResultsExecutorTest.php
new file mode 100644
index 00000000000..d86e7b19f43
--- /dev/null
+++ b/src/Autocomplete/tests/Unit/AutocompleteResultsExecutorTest.php
@@ -0,0 +1,95 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\Autocomplete\Tests\Unit;
+
+use Doctrine\ORM\AbstractQuery;
+use Doctrine\ORM\EntityRepository;
+use Doctrine\ORM\QueryBuilder;
+use PHPUnit\Framework\TestCase;
+use Symfony\Bundle\SecurityBundle\Security\Security;
+use Symfony\Component\Security\Core\Exception\AccessDeniedException;
+use Symfony\UX\Autocomplete\AutocompleteResultsExecutor;
+use Symfony\UX\Autocomplete\Doctrine\DoctrineRegistryWrapper;
+use Symfony\UX\Autocomplete\Doctrine\EntitySearchUtil;
+use Symfony\UX\Autocomplete\EntityAutocompleterInterface;
+
+class AutocompleteResultsExecutorTest extends TestCase
+{
+ public function testItExecutesTheResults()
+ {
+ $entitySearchUtil = $this->createMock(EntitySearchUtil::class);
+ $entitySearchUtil->expects($this->once())
+ ->method('addSearchClause');
+
+ $doctrineRegistry = $this->createMock(DoctrineRegistryWrapper::class);
+ $doctrineRegistry->expects($this->any())
+ ->method('getRepository')
+ ->willReturn($this->createMock(EntityRepository::class));
+
+ $queryBuilder = $this->createMock(QueryBuilder::class);
+ $autocompleter = $this->createMock(EntityAutocompleterInterface::class);
+ $autocompleter->expects($this->once())
+ ->method('getQueryBuilder')
+ ->willReturn($queryBuilder);
+ $autocompleter->expects($this->exactly(2))
+ ->method('getValue')
+ ->willReturnCallback(function (object $object) {
+ return $object->id;
+ });
+ $autocompleter->expects($this->exactly(2))
+ ->method('getLabel')
+ ->willReturnCallback(function (object $object) {
+ return $object->name;
+ });
+
+ $result1 = new \stdClass();
+ $result1->id = 1;
+ $result1->name = 'Result 1';
+ $result2 = new \stdClass();
+ $result2->id = 2;
+ $result2->name = 'Result 2';
+
+ $mockQuery = $this->createMock(AbstractQuery::class);
+ $mockQuery->expects($this->once())
+ ->method('execute')
+ ->willReturn([$result1, $result2]);
+ $queryBuilder->expects($this->once())
+ ->method('getQuery')
+ ->willReturn($mockQuery);
+
+ $executor = new AutocompleteResultsExecutor($entitySearchUtil, $doctrineRegistry);
+ $this->assertEquals([
+ ['value' => 1, 'text' => 'Result 1'],
+ ['value' => 2, 'text' => 'Result 2'],
+ ], $executor->fetchResults($autocompleter, 'foo'));
+ }
+
+ public function testItExecutesSecurity()
+ {
+ $entitySearchUtil = $this->createMock(EntitySearchUtil::class);
+ $doctrineRegistry = $this->createMock(DoctrineRegistryWrapper::class);
+
+ $autocompleter = $this->createMock(EntityAutocompleterInterface::class);
+ $autocompleter->expects($this->once())
+ ->method('isGranted')
+ ->willReturn(false);
+
+ $executor = new AutocompleteResultsExecutor(
+ $entitySearchUtil,
+ $doctrineRegistry,
+ $this->createMock(Security::class)
+ );
+
+ $this->expectException(AccessDeniedException::class);
+ $executor->fetchResults($autocompleter, 'foo');
+ }
+}
diff --git a/src/Autocomplete/tests/bootstrap.php b/src/Autocomplete/tests/bootstrap.php
new file mode 100644
index 00000000000..aa69588ae53
--- /dev/null
+++ b/src/Autocomplete/tests/bootstrap.php
@@ -0,0 +1,16 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+use Zenstruck\Foundry\Test\TestState;
+
+require dirname(__DIR__).'/vendor/autoload.php';
+
+TestState::disableDefaultProxyAutoRefresh();
diff --git a/src/React/Resources/assets/dist/register_controller.js b/src/React/Resources/assets/dist/register_controller.js
index 564eb6128e1..eff0a3e32c0 100644
--- a/src/React/Resources/assets/dist/register_controller.js
+++ b/src/React/Resources/assets/dist/register_controller.js
@@ -5,7 +5,7 @@ function registerReactControllerComponents(context) {
};
importAllReactComponents(context);
window.resolveReactComponent = (name) => {
- const component = reactControllers['./' + name + '.jsx'] || reactControllers['./' + name + '.tsx'];
+ const component = reactControllers[`./${name}.jsx`] || reactControllers[`./${name}.tsx`];
if (typeof component === 'undefined') {
throw new Error('React controller "' + name + '" does not exist');
}
diff --git a/src/React/Resources/assets/dist/render_controller.js b/src/React/Resources/assets/dist/render_controller.js
index d6e5ba59e60..01721bdd02e 100644
--- a/src/React/Resources/assets/dist/render_controller.js
+++ b/src/React/Resources/assets/dist/render_controller.js
@@ -1,6 +1,24 @@
import React from 'react';
+import require$$0 from 'react-dom';
import { Controller } from '@hotwired/stimulus';
+var createRoot;
+
+var m = require$$0;
+if (process.env.NODE_ENV === 'production') {
+ createRoot = m.createRoot;
+} else {
+ var i = m.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED;
+ createRoot = function(c, o) {
+ i.usingClientEntryPoint = true;
+ try {
+ return m.createRoot(c, o);
+ } finally {
+ i.usingClientEntryPoint = false;
+ }
+ };
+}
+
class default_1 extends Controller {
connect() {
this._dispatchEvent('react:connect', { component: this.componentValue, props: this.propsValue });
@@ -13,23 +31,13 @@ class default_1 extends Controller {
});
}
disconnect() {
- this.element.unmount();
+ this.element.root.unmount();
this._dispatchEvent('react:unmount', { component: this.componentValue, props: this.propsValue });
}
_renderReactElement(reactElement) {
- if (parseInt(React.version) >= 18) {
- const root = require('react-dom/client').createRoot(this.element);
- root.render(reactElement);
- this.element.unmount = () => {
- root.unmount();
- };
- return;
- }
- const reactDom = require('react-dom');
- reactDom.render(reactElement, this.element);
- this.element.unmount = () => {
- reactDom.unmountComponentAtNode(this.element);
- };
+ const root = createRoot(this.element);
+ root.render(reactElement);
+ this.element.root = root;
}
_dispatchEvent(name, payload) {
this.element.dispatchEvent(new CustomEvent(name, { detail: payload, bubbles: true }));