EasyCorp / EasyAdminBundle

EasyAdmin is a fast, beautiful and modern admin generator for Symfony applications.
MIT License
4.08k stars 1.02k forks source link

[3.2] Autocomplete field from external API datas #4305

Open ghost opened 3 years ago

ghost commented 3 years ago

Hi,

I'd like to know if it's possible to create autocomplete field with external datas. I'have an external API, and I'd like to populate my field with theses datas.

Best,

Schyzophrenic commented 3 years ago

I have a similar requirement. As a workaround, I thought about adding a non entity related field with a bit of Javascript which would populate another field (hidden?) when the "external drop down" is modified. I haven't add the time to do it yet (and still struggling a bit with some of EA features...). @a-leclerc let me know if you got anywhere please? PS: It would be great to start collecting these recipes in a doc...

ghost commented 3 years ago

Hi,

I did it :)

I created a new Field : /src/Admin/Field/AddressField.php

<?php

namespace App\Admin\Field;

use App\Form\Type\AddressGouvType;
use EasyCorp\Bundle\EasyAdminBundle\Contracts\Field\FieldInterface;
use EasyCorp\Bundle\EasyAdminBundle\Field\CollectionField;
use EasyCorp\Bundle\EasyAdminBundle\Field\FieldTrait;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;

final class AddressField implements FieldInterface
{
    use FieldTrait;

    public static function new(string $propertyName, ?string $label = null): self
    {
        return (new self())
            ->setProperty($propertyName)
            //->setLabel($label)
            ->setTemplatePath('admin/field/address.html.twig')
            ->setFormType(AddressGouvType::class)
            ->addCssClass('field-address')
            ->addCssFiles('https://cdn.jsdelivr.net/npm/@tarekraafat/autocomplete.js@9.0.0/dist/css/autoComplete.min.css')
            ->addJsFiles('https://cdn.jsdelivr.net/npm/@tarekraafat/autocomplete.js@9.0.0/dist/js/autoComplete.min.js')
            ->addJsFiles('admin/field/address/field-address.js')
            ->addCssFiles('admin/field/address/field-address.css')
            ;
    }
}

which is related to : /src/Form/Type/AddressGouvType.php

<?php

namespace App\Form\Type;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Component\Form\FormBuilderInterface;

class AddressGouvType extends AbstractType
{
    private $router;

    public function __construct(RouterInterface $router)
    {
        $this->router = $router;
    }

    public function getParent()
    {
        return TextType::class;
    }

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults([
            'finder_callback' => function(string $query) {
            },
            'attr' => [
                'class' => 'js-field-address-autocomplete',
                'data-autocomplete-url' => $this->router->generate('api_address_search')
            ]
        ]);
    }
}

Next, I've created a js file : /public/admin/field/field-address.js

new autoComplete({
  selector: ".js-field-address-autocomplete",
  placeHolder: "Cherchez une adresse...",
  data: {
    src: async () => {
      let query = document.querySelector(".js-field-address-autocomplete").value;
      const source = await fetch("/api/address/search/?q=" + query);
      const data = await source.json();
      return data;
    },
    key: ["address"],
    cache: false
  },
  trigger: {
    event: ["input", "focus"]
  },
  resultsList: {
    noResults: (list, query) => {
      console.log(list);
      // Create "No Results" message list element
      const message = document.createElement("li");
      message.setAttribute("class", "no_result");
      // Add message text content
      message.innerHTML = `<span>Aucun résultat pour "${query}"</span>`;
      // Add message list element to the list
      list.appendChild(message);
    },
  },
  resultItem: {
    highlight: {
      render: true
    }
  },
  onSelection: (feedback) => {
    document.querySelector(".js-field-address-autocomplete").blur();
    // Prepare User's Selected Value
    const selection = feedback.selection.value[feedback.selection.key];
    // Render selected choice to selection div
    //document.querySelector(".selection").innerHTML = selection;
    // Replace Input value with the selected value
    document.querySelector(".js-field-address-autocomplete").value = selection;
    // Console log autoComplete data feedback
    //console.log(feedback);
  }
});

And custom CSS : /public/admin/field/field-address.css

#autoComplete_list {
  position: relative;
  top: 10px;
}
.autoComplete_result {
  font-size: 0.8rem;
}
li.no_result {
  list-style-type: none;
  padding: 0.5rem;
}
.js-field-address-autocomplete {
  max-width: 100% !important;
}

I created also an API : /src/Controller/Api/External/AddressController.php

<?php

namespace App\Controller\Api\External;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Contracts\HttpClient\HttpClientInterface;

/**
 * @Route("/api", name="api_")
 */
class AddressController extends AbstractController
{
    private $client;

    public function __construct(HttpClientInterface $client)
    {
        $this->client = $client;
    }

    /**
     * @Route("/address/search/", methods="GET", name="address_search")
     */
    public function search(Request $request): ?string
    {

        $request = $request->query->get('q');

        $response = $this->client->request(
            'GET',
            'https://xxx/search/?q=' . urlencode( $request )
        );

        $response = $response->toArray();
        $json = $response['features'];

        $output = [];
        if ( !empty( $json ) ) {
            foreach ( $json as $property ) {
                $address = [
                    $property['properties']['name'],
                    $property['properties']['postcode'],
                    $property['properties']['city'],
                    $property['geometry']['coordinates'][0],
                    $property['geometry']['coordinates'][1]
                ];
                $output[] = [
                    'address' => implode(', ', $address),
                ];
            }
        }

        $response = new Response(
            json_encode($output),
            Response::HTTP_OK,
            ['content-type' => 'application/json']
        );

        $response->send();
    }
}

And I add my field in my admin panel : /src/Controller/Admin/BusinessCrubController.php

<?php

namespace App\Controller\Admin;

use App\Admin\Field\AddressField;
use App\Entity\Business;
use EasyCorp\Bundle\EasyAdminBundle\Config\MenuItem;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField;
use EasyCorp\Bundle\EasyAdminBundle\Field\BooleanField;
use EasyCorp\Bundle\EasyAdminBundle\Field\IdField;
use EasyCorp\Bundle\EasyAdminBundle\Field\SlugField;
use EasyCorp\Bundle\EasyAdminBundle\Field\TelephoneField;
use EasyCorp\Bundle\EasyAdminBundle\Field\TextEditorField;
use EasyCorp\Bundle\EasyAdminBundle\Field\TextField;
use EasyCorp\Bundle\EasyAdminBundle\Field\UrlField;
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;

class BusinessCrudController extends AbstractCrudController
{
    ...

    public function configureFields(string $pageName): iterable
    {
        ...
        yield AddressField::new('address', 'Adresse')
            ->setHelp('Commencez à taper pour trouver l\'adresse.')
            ->hideOnIndex()
            ->hideOnDetail()
        ;
        ...
    }
}

I've used autoComplete vanilla for autocomplete javascript library.

Hope it will help you @Schyzophrenic

Schyzophrenic commented 3 years ago

Man, that looks really good! Let me digest it and give it a try! I am not too familiar with the Promess but I should get my head around it! Thanks a lot for sharing, I am sure this is going to be very handy!

Schyzophrenic commented 3 years ago

Hello again @a-leclerc , I finally have the time to look into this. As I was integrating and adapting to my use case I noticed you didn't put the file admin/field/address.html.twig. Would you be kind enough to share it?

Thank you again, this is going to save me a lot of time!

ghost commented 3 years ago

Hi, yes excuse me :) here is the file :

{# @var ea \EasyCorp\Bundle\EasyAdminBundle\Context\AdminContext #}
{# @var field \EasyCorp\Bundle\EasyAdminBundle\Dto\FieldDto #}
{# @var entity \EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto #}
{% set render_as_html = field.customOptions.get('renderAsHtml') %}
{% if ea.crud.currentAction == 'detail' %}
    <span title="{{ field.value }}">
        {{ render_as_html ? field.formattedValue|raw|nl2br : field.formattedValue|nl2br }}
    </span>
{% else %}
    <span title="{{ field.value }}">
        {{ render_as_html ? field.formattedValue|raw : field.formattedValue|striptags }}
    </span>
{% endif %}
Schyzophrenic commented 3 years ago

Thanks @a-leclerc ! This is actually exactly the same as the one I wrote so I am going to dig deeper why it is not working! (First time I use the vanilla autocomplete so I assume I didn't interface the json and the lib properly). Thank you again, I'll report back on my progress ;)

ghost commented 3 years ago

Have you any error message ?

Schyzophrenic commented 3 years ago

Have you any error message ?

So actually, I have reduced the complexity to add it back in gradually (and help me understand better). I removed the Ajax call now but still have no results. It seems to be due to the position absolute of #autocomplete_List.

I see you added the below, but it is not taken into account for some reason.

#autoComplete_list {
    position: relative !important;
    top: 10px;
}

When I remove the absolute attribute in the chrome editor, I see the results just fine.

A bit of an edit here: It seems the issue lives in the #autoComplete_list class defined by vanilla autocomplete as I wish the results to be displayed on top of the form. In order to do this, I need to remove the top, left and right attribute of the associate css class.

So far, the only way I found was to store a version of the autocomplete css locally and modify it, not ideal.

Re-reading a code is always useful... After replacing ->addCssClass by ->addCssFiles, it works much better.... Continuing with the integration.

Schyzophrenic commented 3 years ago

@a-leclerc Mission accomplished, thank you for the great help!

Schyzophrenic commented 3 years ago

Hey @a-leclerc . I just updated EasyAdmin to the latest version and this is now generating a javascript error. Uncaught TypeError: Cannot read property 'setAttribute' of null at e.value (autoComplete.min.js:1) at new e (autoComplete.min.js:1) at igdb-game-finder.js:3

I am looking into it but let me know if you're facing the same!

Edit: It is actually quite simple (and written in the upgrade notes): Just encapsulate the function into the DOMContentLoaded listener as the scripts are now loaded in the head rather than the end of the body

document.addEventListener('DOMContentLoaded', () => {
// Your function
}
Schyzophrenic commented 3 years ago

For those looking at this thread, this is now broken again after migration to 3.5 with Uncaught TypeError: Cannot read properties of null (reading 'setAttribute')

This seems to be due to the class parameters not being written in the form for some reason. This is probably due to the upgrade to Bootstrap 5 and the associated twig templates, but to be confirmed

Schyzophrenic commented 3 years ago

I fixed my issue via #4643

didiramzi commented 1 year ago

thank you @a-leclerc for your solution, it works very well, however I would like to know if there is a way to use a select (or some thing else) instead of a text field, the goal is to search a string and display it in the field but what we save is an id (which is returned by the API). Currently I managed the solution to retrieve the id and put it in an hidden field but it's impossible to persist it instead of the text field used for the research. thank you in advance for your answer

SimonChabrier commented 5 months ago

Very nice Ghost thank you !