diff --git a/package.json b/package.json index ed9d85209a9..817fbe80478 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,8 @@ "@babel/preset-env": "^7.15.8", "@babel/preset-react": "^7.15.8", "@babel/preset-typescript": "^7.15.8", - "@rollup/plugin-node-resolve": "^13.0.0", + "@rollup/plugin-commonjs": "^22.0.0", + "@rollup/plugin-node-resolve": "^13.0.6", "@rollup/plugin-typescript": "^8.3.0", "@symfony/stimulus-testing": "^2.0.1", "@typescript-eslint/eslint-plugin": "^5.2.0", @@ -29,7 +30,7 @@ "jest": "^27.3.1", "pkg-up": "^3.1.0", "prettier": "^2.2.1", - "rollup": "^2.52.2", + "rollup": "^2.68.0", "tslib": "^2.3.1", "typescript": "^4.4.4" }, diff --git a/rollup.config.js b/rollup.config.js index 9b814f97d61..c3b1f1d102c 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -1,4 +1,5 @@ import resolve from '@rollup/plugin-node-resolve'; +import commonjs from '@rollup/plugin-commonjs'; import typescript from '@rollup/plugin-typescript'; import glob from 'glob'; import path from 'path'; @@ -56,6 +57,11 @@ const packages = files.map((file) => { plugins: [ resolve(), typescript(), + commonjs({ + namedExports: { + 'react-dom/client': ['createRoot'], + }, + }), wildcardExternalsPlugin(peerDependencies) ], }; diff --git a/src/Autocomplete/.gitattributes b/src/Autocomplete/.gitattributes new file mode 100644 index 00000000000..18e14aad7f0 --- /dev/null +++ b/src/Autocomplete/.gitattributes @@ -0,0 +1 @@ +/tests export-ignore diff --git a/src/Autocomplete/.gitignore b/src/Autocomplete/.gitignore new file mode 100644 index 00000000000..854217846fe --- /dev/null +++ b/src/Autocomplete/.gitignore @@ -0,0 +1,5 @@ +/composer.lock +/phpunit.xml +/vendor/ +/var/ +/.phpunit.result.cache diff --git a/src/Autocomplete/.symfony.bundle.yaml b/src/Autocomplete/.symfony.bundle.yaml new file mode 100644 index 00000000000..17001f559e7 --- /dev/null +++ b/src/Autocomplete/.symfony.bundle.yaml @@ -0,0 +1,3 @@ +branches: ["2.x"] +maintained_branches: ["2.x"] +doc_dir: "src/Resources/doc" diff --git a/src/Autocomplete/LICENSE b/src/Autocomplete/LICENSE new file mode 100644 index 00000000000..45c069b323b --- /dev/null +++ b/src/Autocomplete/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/Autocomplete/README.md b/src/Autocomplete/README.md new file mode 100644 index 00000000000..d0374b9dd43 --- /dev/null +++ b/src/Autocomplete/README.md @@ -0,0 +1,16 @@ +# UX Autocomplete + +Javascript-powered auto-completion functionality for your Symfony forms! + +**EXPERIMENTAL** This component is currently experimental and is +likely to change, or even change drastically. + +**This repository is a READ-ONLY sub-tree split**. See +https://github.com/symfony/ux to create issues or submit pull requests. + +## Resources + +- [Documentation](https://symfony.com/bundles/ux-autocomplete/current/index.html) +- [Report issues](https://github.com/symfony/ux/issues) and + [send Pull Requests](https://github.com/symfony/ux/pulls) + in the [main Symfony UX repository](https://github.com/symfony/ux) diff --git a/src/Autocomplete/assets/.gitignore b/src/Autocomplete/assets/.gitignore new file mode 100644 index 00000000000..2ccbe4656c6 --- /dev/null +++ b/src/Autocomplete/assets/.gitignore @@ -0,0 +1 @@ +/node_modules/ diff --git a/src/Autocomplete/assets/dist/controller.js b/src/Autocomplete/assets/dist/controller.js new file mode 100644 index 00000000000..a25187c2374 --- /dev/null +++ b/src/Autocomplete/assets/dist/controller.js @@ -0,0 +1,165 @@ +import { Controller } from '@hotwired/stimulus'; +import TomSelect from 'tom-select'; + +/*! ***************************************************************************** +Copyright (c) Microsoft Corporation. + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. +***************************************************************************** */ + +function __classPrivateFieldGet(receiver, state, kind, f) { + if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter"); + if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it"); + return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver); +} + +var _instances, _getCommonConfig, _createAutocomplete, _createAutocompleteWithHtmlContents, _createAutocompleteWithRemoteData, _stripTags, _mergeObjects, _createTomSelect, _dispatchEvent; +class default_1 extends Controller { + constructor() { + super(...arguments); + _instances.add(this); + } + connect() { + if (this.tomSelect) { + return; + } + if (this.urlValue) { + this.tomSelect = __classPrivateFieldGet(this, _instances, "m", _createAutocompleteWithRemoteData).call(this, this.urlValue); + return; + } + if (this.optionsAsHtmlValue) { + this.tomSelect = __classPrivateFieldGet(this, _instances, "m", _createAutocompleteWithHtmlContents).call(this); + return; + } + this.tomSelect = __classPrivateFieldGet(this, _instances, "m", _createAutocomplete).call(this); + } + get selectElement() { + if (!(this.element instanceof HTMLSelectElement)) { + return null; + } + return this.element; + } + get formElement() { + if (!(this.element instanceof HTMLInputElement) && !(this.element instanceof HTMLSelectElement)) { + throw new Error('Autocomplete Stimulus controller can only be used no an or or + `); + + 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(` + + + `); + + 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 `` 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 + + + +.. _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) }} + + +{{ 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 }));