symfony / ux

Symfony UX initiative: a JavaScript ecosystem for Symfony
https://ux.symfony.com/
MIT License
853 stars 314 forks source link

[Autocomplete] Impossible to define custom options in a EntityAutocompleteType class #420

Open garak opened 2 years ago

garak commented 2 years ago

Description

I followed the documentation of UX-Autocomplete and I created my custom form type. Problem is that I expect to be able to use some custom options. I can't. It looks like the AutocompleteEntityTypeSubscriber is just assuming you don't use any custom options and it's passing all the options to an "autocomplete" field. See https://github.com/symfony/ux-autocomplete/blob/2.x/src/Form/AutocompleteEntityTypeSubscriber.php#L44

How to reproduce

Just follow the instructions provided in documentation https://symfony.com/bundles/ux-autocomplete/current/index.html#usage-in-a-form-with-ajax and add a custom option, like "allowed_states" in the example provided in https://symfony.com/doc/current/form/create_custom_field_type.html#adding-configuration-options-for-the-form-type. As soon as you try to use the form with your custom form type (no matter if you use your custom option or not), you get the following exception:

An error has occurred resolving the options of the form "Symfony\Bridge\Doctrine\Form\Type\EntityType": The option "allowed_states" does not exist. Defined options are: "action", "allow_extra_fields", "allow_file_upload", "allow_options_create", "attr", "attr_translation_parameters", "auto_initialize", "autocomplete", "autocomplete_url", "block_name", "block_prefix", "by_reference", "choice_attr", "choice_filter", "choice_label", "choice_loader", "choice_name", "choice_translation_domain", "choice_translation_parameters", "choice_value", "choices", "class", "compound", "constraints", "csrf_field_name", "csrf_message", "csrf_protection", "csrf_token_id", "csrf_token_manager", "data", "data_class", "disabled", "em", "empty_data", "error_bubbling", "error_mapping", "expanded", "extra_fields_message", "form_attr", "getter", "group_by", "help", "help_attr", "help_html", "help_translation_parameters", "id_reader", "inherit_data", "invalid_message", "invalid_message_parameters", "is_empty_callback", "label", "label_attr", "label_format", "label_html", "label_translation_parameters", "mapped", "method", "multiple", "no_more_results_text", "no_results_found_text", "options_as_html", "placeholder", "post_max_size_message", "preferred_choices", "priority", "property_path", "query_builder", "required", "row_attr", "setter", "tom_select_options", "translation_domain", "trim", "upload_max_size_message", "validation_groups".

Possible Solution

I don't know if the current implementation could be fixed. If not, the documentation should be updated, adding a big warning that the developer must not define any custom option in their custom form type.

weaverryan commented 2 years ago

Hey @garak!

Hmm, yes I see! I also don't think this is fixable... and I think it goes beyond the technical complication of not knowing which options to pass or not. Take this example: https://github.com/symfony/ux/blob/2.x/ux.symfony.com/src/Form/FoodAutocompleteField.php

If we added a custom option to this, when the Ajax request is made to fetch the "results" for the autocomplete system, we will be "missing" this option. The Ajax request basically just says "give me the options for FoodAutocompleteField for the query foo - but no other information is "sent up".

So yes, I think this comes down to a documentation issue - good catch! The solution is to have a separate class for each "variant" of the option. If that is unrealistic, see #391 about allowing some extra "context" to be passed to the Ajax autocomplete URL. But, I'm not sure if we could allow that context to be passed in via an option (since, again, we don't know which options to pass).

janklan commented 1 year ago

Can't we use another OptionsResolver value to tell AutocompleteEntityTypeSubscriber which options to remove from the options passed to the EntityType?

That way, the list of options to unset wouldn't have to be hardcoded in AutocompleteEntityTypeSubscriber and if you happen to add custom options in your Autocomplete type, you could also flag them for removal.

weaverryan commented 1 year ago

Could you include some faux code here to elaborate on your thinking?

janklan commented 1 year ago

Could you include some faux code here

I can do better than that. Check the PR. It works. We only need to prevent the options from being passed to the EntityType.

janklan commented 1 year ago

Eh... haha. Hang on.

janklan commented 1 year ago

That's better

arlenreyb commented 9 months ago

Sorry to grave-dig, but is there any update on this?

Having a separate class for each Autocomplete variant isn't really a viable option.

Case 1: searching for a product with a boolean "isDeleted" field. You'll need to make 3 variants, one for true, one for false, and one that doesn't care either way. But what if you have two boolean fields? Ex: isDeleted and isVisible? Now you have to make nine Autocomplete variants.

Case 2: searching for a product within a specific category. A form may have one input where you select a category, and then (using form event listeners) the Autocomplete is adjusted to only return results within that category. In this case, you'll have to make an Autocomplete variant for every single category in your database. Every time a new category is made, the programmer will have to make a new Autocomplete variant class...

I may be way off the mark, since I'm no veteran programmer, but... If the issue is about custom options, what about this: the query_builder option is designed to accept a QueryBuilder class right? I have mine set up like this, so it just pulls from a repository (because that's better than writing it out in each Autocomplete class variant).

<?php

namespace App\Form\Autocomplete;

use App\Entity\PurchaseOrder;
use App\Repository\PurchaseOrderRepository; 
use Symfony\Component\Form\AbstractType;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\UX\Autocomplete\Form\AsEntityAutocompleteField;
use Symfony\UX\Autocomplete\Form\BaseEntityAutocompleteType;

#[AsEntityAutocompleteField]
class PurchaseOrderAutocomplete extends AbstractType
{
    public function __construct(
        private PurchaseOrderRepository $purchaseOrderRepository,
    ) {
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'preload' => false,
            'class' => PurchaseOrder::class,
            'placeholder' => 'Search for a purchase order...',
            'searchable_fields' => ['orderNumber'],
            'choice_label' => 'POSelectOption',
            'query_builder' => $this->purchaseOrderRepository->POAutocompleteQuery($showDeleted = true),
        ]);
    }

    public function getParent(): string
    {
        return BaseEntityAutocompleteType::class;
    }
}

Here's what that repository method looks like:

    public function POAutocompleteQuery(bool $showDeleted = false): QueryBuilder 
    {
        $qb = $this->createQueryBuilder('purchaseOrder')
            ->addSelect('inventorySupplier')
            ->join('purchaseOrder.inventorySupplier', 'inventorySupplier')
            ->orderBy('purchaseOrder.date', 'desc');

        if (false === $showDeleted) {
            $qb->andWhere("purchaseOrder.status != 'deleted'");
        }

        return $qb;
    }

That works, and if I go and change the true to false in the Autocomplete class, it works as expected. But what if you took OUT the query_builder line in the Autocomplete class, and instead did something like this from the form builder:

<?php

namespace App\Form\Modal;

use App\Form\Autocomplete\PurchaseOrderAutocomplete;
use App\Repository\PurchaseOrderRepository;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class ReceivingEventSearchForm extends AbstractType
{
    public function __construct(
        private PurchaseOrderRepository $purchaseOrderRepository,
    ) {
    }

    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            .....
            ->add('purchaseOrder', PurchaseOrderAutocomplete::class, [
                'query_builder' => ($options['showDeleted'])
                    ? $this->purchaseOrderRepository->POAutocompleteQuery(true)
                    : $this->purchaseOrderRepository->POAutocompleteQuery(false),
            ]);
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            // Passed parameters
            'showDeleted' => false,
        ]);
    }
}   

This won't work, and as I understand it, it's because the configuration ignores you when you pass the query_builder from the form builder. But is there some other reason this wouldn't work? Because this way, we're not worrying about custom options, we're using an option that's already expected by the Autocomplete class.

janklan commented 9 months ago

I ended up using a standard EntityType with autocomplete=true and custom autocomplete_url options. The custom URL provides the results I need, and the EntityType has a query builder aligned with what the URL can generate to ensure no rogue value can ever be accepted.