diff --git a/assets/js/autocomplete.js b/assets/js/autocomplete.js index 7f152cc614..f8765f9d2f 100644 --- a/assets/js/autocomplete.js +++ b/assets/js/autocomplete.js @@ -110,7 +110,13 @@ export default class Autocomplete { labelField: 'entityAsString', searchField: ['entityAsString'], firstUrl: (query) => { - return `${autocompleteEndpointUrl}&query=${encodeURIComponent(query)}`; + const url = new URL(autocompleteEndpointUrl); + url.searchParams.append(`query`, query); + this.#resolveAutocompleteDependencyFields(element).forEach((dependency) => { + url.searchParams.append(`autocompleteDependsOn[${dependency.path}]`, dependency.element.value); + }); + + return url.toString(); }, // VERY IMPORTANT: use 'function (query, callback) { ... }' instead of the // '(query, callback) => { ... }' syntax because, otherwise, @@ -145,7 +151,11 @@ export default class Autocomplete { }, }); - return this.#initializeTomSelect(element, config); + const tomSelect = this.#initializeTomSelect(element, config); + + this.#bindAutocompleteDependencyFieldListeners(tomSelect); + + return tomSelect; } #initializeTomSelect(element, config) { @@ -258,4 +268,41 @@ export default class Autocomplete { return { options, optgroups }; } + + #bindAutocompleteDependencyFieldListeners(tomSelect) { + this.#resolveAutocompleteDependencyFields(tomSelect.input).forEach((dependency) => { + dependency.element.addEventListener('change', () => { + tomSelect.clear(); + tomSelect.clearOptions(); + tomSelect.clearOptionGroups(); + tomSelect.clearPagination(); + tomSelect.wrapper.classList.remove('preloaded'); + }); + }); + } + + #resolveAutocompleteDependencyFields(element) { + const dependsOn = JSON.parse(element.getAttribute('data-ea-autocomplete-depends-on') || '[]'); + const form = element.closest('form'); + + if (null === form) { + return []; + } + + return dependsOn.map((field) => { + const fieldId = `${form.name}_${field.replaceAll('.', '_')}`; + const fieldElement = document.getElementById(fieldId) ?? document.getElementById(`${fieldId}_autocomplete`); + + if (null === fieldElement) { + console.error(`No field found for autocomplete dependency "${field}".`); + return null; + } + + return { + path: field, + element: fieldElement, + }; + }) + .filter((dependencyField) => null !== dependencyField); + } } diff --git a/src/Controller/AbstractCrudController.php b/src/Controller/AbstractCrudController.php index cc73525fd2..a489914be9 100644 --- a/src/Controller/AbstractCrudController.php +++ b/src/Controller/AbstractCrudController.php @@ -561,7 +561,8 @@ public function autocomplete(AdminContext $context): JsonResponse $queryBuilderCallable = $field?->getCustomOption(AssociationField::OPTION_QUERY_BUILDER_CALLABLE); if (null !== $queryBuilderCallable) { - $queryBuilder = $queryBuilderCallable($queryBuilder) ?? $queryBuilder; + $autocompleteDependsOn = $context->getRequest()->query->all('autocompleteDependsOn'); + $queryBuilder = $queryBuilderCallable($queryBuilder, $autocompleteDependsOn) ?? $queryBuilder; } $callback = $field?->getCustomOption(AssociationField::OPTION_AUTOCOMPLETE_CALLBACK) diff --git a/src/Field/AssociationField.php b/src/Field/AssociationField.php index a70b9ebd84..32bdae5bca 100644 --- a/src/Field/AssociationField.php +++ b/src/Field/AssociationField.php @@ -16,6 +16,7 @@ final class AssociationField implements FieldInterface public const OPTION_AUTOCOMPLETE = 'autocomplete'; public const OPTION_AUTOCOMPLETE_CALLBACK = 'autocompleteCallback'; public const OPTION_AUTOCOMPLETE_TEMPLATE = 'autocompleteTemplate'; + public const OPTION_AUTOCOMPLETE_DEPENDS_ON = 'autocompleteDependsOn'; public const OPTION_EMBEDDED_CRUD_FORM_CONTROLLER = 'crudControllerFqcn'; public const OPTION_WIDGET = 'widget'; public const OPTION_QUERY_BUILDER_CALLABLE = 'queryBuilderCallable'; @@ -52,6 +53,7 @@ public static function new(string $propertyName, TranslatableInterface|string|bo ->setCustomOption(self::OPTION_AUTOCOMPLETE, false) ->setCustomOption(self::OPTION_AUTOCOMPLETE_CALLBACK, null) ->setCustomOption(self::OPTION_AUTOCOMPLETE_TEMPLATE, null) + ->setCustomOption(self::OPTION_AUTOCOMPLETE_DEPENDS_ON, null) ->setCustomOption(self::OPTION_EMBEDDED_CRUD_FORM_CONTROLLER, null) ->setCustomOption(self::OPTION_WIDGET, self::WIDGET_AUTOCOMPLETE) ->setCustomOption(self::OPTION_QUERY_BUILDER_CALLABLE, null) @@ -64,7 +66,10 @@ public static function new(string $propertyName, TranslatableInterface|string|bo ->setCustomOption(self::OPTION_PREFERRED_CHOICES, null); } - public function autocomplete(bool $enable = true, ?callable $callback = null, ?string $template = null, bool $renderAsHtml = false): self + /** + * @param list|string|null $dependsOn + */ + public function autocomplete(bool $enable = true, ?callable $callback = null, ?string $template = null, bool $renderAsHtml = false, array|string|null $dependsOn = null): self { if (!$enable) { return $this; @@ -83,6 +88,10 @@ public function autocomplete(bool $enable = true, ?callable $callback = null, ?s // the renderAsHtml parameter controls the same option as renderAsHtml() method $this->setCustomOption(self::OPTION_ESCAPE_HTML_CONTENTS, !$renderAsHtml); + if (null !== $dependsOn) { + $this->setCustomOption(self::OPTION_AUTOCOMPLETE_DEPENDS_ON, \is_string($dependsOn) ? [$dependsOn] : $dependsOn); + } + return $this; } diff --git a/src/Field/Configurator/AssociationConfigurator.php b/src/Field/Configurator/AssociationConfigurator.php index c30b90b339..46fdde35a4 100644 --- a/src/Field/Configurator/AssociationConfigurator.php +++ b/src/Field/Configurator/AssociationConfigurator.php @@ -203,6 +203,11 @@ public function configure(FieldDto $field, EntityDto $entityDto, AdminContext $c } elseif (null !== $autocompleteTemplate) { $field->setFormTypeOption('autocomplete_template', $autocompleteTemplate); } + + $autocompleteDependsOn = $field->getCustomOption(AssociationField::OPTION_AUTOCOMPLETE_DEPENDS_ON); + if (null !== $autocompleteDependsOn) { + $field->setFormTypeOption('attr.data-ea-autocomplete-depends-on', json_encode($autocompleteDependsOn)); + } } else { $field->setFormTypeOptionIfNotSet('query_builder', static function (EntityRepository $repository) use ($field) { // TODO: should this use `createIndexQueryBuilder` instead, so we get the default ordering etc.?