Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Hooking up the AJAX Autocomplete

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $12.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

We now have an endpoint that returns all users as JSON. And we have some autocomplete JavaScript that... ya know... autocompletes entries for us. I have a crazy idea: let's combine these two so that our autocomplete uses that Ajax endpoint!

Adding a data-autocomplete-url Attribute

First: inside of the JavaScript, we need to know what the URL is to this endpoint. We could hardcode this - I wouldn't judge you for doing that - this is a no-judgment zone. But, there is a simple, clean solution.

In AdminUtilityController, let's give our new route a name: admin_utility_users. Now, idea time: when we render the field, what if we added a "data" attribute onto the input field that pointed to this URL? If we did that, it would be super easy to read that from JavaScript.

... lines 1 - 10
class AdminUtilityController extends AbstractController
{
/**
* @Route("/admin/utility/users", methods="GET", name="admin_utility_users")
... line 15
*/
public function getUsersApi(UserRepository $userRepository)
... lines 18 - 24
}

Let's do it! In UserSelectTextType, add another attribute: how about data-autocomplete-url set to... hmm. We need to generate the URL to our new route. How do we generate a URL from inside of a service? Answer: by using the router service. Add a second argument to the constructor: RouterInterface $router. I'll hit Alt+Enter to add that property and set it.

... lines 1 - 12
class UserSelectTextType extends AbstractType
{
... line 15
private $router;
... line 17
public function __construct(UserRepository $userRepository, RouterInterface $router)
{
... line 20
$this->router = $router;
}
... lines 23 - 49
}

Oh, and if you can't remember the type-hint to use, at least make sure that you remember that you can run:

php bin/console debug:autowiring

to see a full list of type-hints. By the way, in Symfony 4.2, this output will look a little bit different, but contains the same info. If you search for the word "route" without the e... cool! We have a few different type-hints, but they all return the same service anyways.

Now that we've injected the router, down below, use $this->router->generate() and pass it the new route name: admin_utility_users.

... lines 1 - 36
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
... lines 40 - 43
'attr' => [
... line 45
'data-autocomplete-url' => $this->router->generate('admin_utility_users')
]
]);
}
... lines 50 - 51

Let's check it out! Refresh, inspect that field and ... perfect! We have a shiny new data-autocomplete-url attribute.

Making the AJAX Call

Let's head to our JavaScript! I'm going to write this a little bit different - though it would work either way: let's find all of the elements... there will be just one in this case... and loop over them with .each(). Indent the inner code, then close the extra function.

$(document).ready(function() {
$('.js-user-autocomplete').each(function() {
... lines 3 - 17
});
});

Now we can change the selector to this and... yea! We're basically doing the same thing as before. Inside the loop, fetch the URL with var autocompleteUrl = $(this).data() to read that new attribute.

... line 1
$('.js-user-autocomplete').each(function() {
var autocompleteUrl = $(this).data('autocomplete-url');
... lines 4 - 17
});
... lines 19 - 20

Finally, clear out the source attribute. Since we're using jQuery already, let's use it to make the AJAX call: $.ajax() with a url option set to autocompleteUrl. That's it!

To handle the result, chain a .then() onto the Promise and pass a callback with a data argument. Let's see: our job is to execute the cb callback and pass it an array of the results.

Remember: in the controller, I'm returning all the user information on a users key. So, let's return data.users: that should return this entire array of data.

... lines 1 - 4
$(this).autocomplete({hint: false}, [
{
source: function(query, cb) {
$.ajax({
url: autocompleteUrl
}).then(function(data) {
cb(data.users);
});
},
... lines 14 - 15
}
])
... lines 18 - 20

But also remember that, by default, the autocomplete library expects each result to have a value key that it uses. Obviously, our key is called email. To change that behavior, add displayKey: 'email'. I'll also add debounce: 500 - that will make sure that we don't make AJAX requests faster than once per half a second.

... lines 1 - 4
$(this).autocomplete({hint: false}, [
{
... lines 7 - 13
displayKey: 'email',
debounce: 500 // only request every 1/2 second
}
])
... lines 18 - 20

Ok... I think we're ready! Let's try this! Move back to your browser, refresh the page and clear out the author field... "spac"... we got it! Though... it still returns all of the users - the geordi users should not match.

Filtering the Users

That's no surprise: our endpoint always returns every user. No worries - this is the easiest part! Go back to the JavaScript. The source function is passed a query argument: that's equal to whatever is typed into the input box at that moment. Let's use that! Add a '?query='+query to the URL.

... lines 1 - 6
source: function(query, cb) {
$.ajax({
url: autocompleteUrl+'?query='+query
... lines 10 - 11
});
},
... lines 14 - 20

Back in AdminUtilityController, to read that, add a second argument, the Request object from HttpFoundation. Then, let's call a new method on UserRepository, how about findAllMatching(). Pass this the ?query= GET parameter by calling $request->query->get('query').

... lines 1 - 8
use Symfony\Component\HttpFoundation\Request;
... lines 10 - 11
class AdminUtilityController extends AbstractController
{
... lines 14 - 17
public function getUsersApi(UserRepository $userRepository, Request $request)
{
$users = $userRepository->findAllMatching($request->query->get('query'));
... lines 21 - 24
}
}

Nice! Copy the method name and then open src/Repository/UserRepository.php. Add the new public function findAllMatching() and give it a string $query argument. Let's also add an optional int $limit = 5 argument, because we probably shouldn't return 1000 users if 1000 users match the query. Advertise that this will return an array of User objects.

... lines 1 - 14
class UserRepository extends ServiceEntityRepository
{
... lines 17 - 33
/**
* @return User[]
*/
public function findAllMatching(string $query, int $limit = 5)
{
... lines 39 - 44
}
... lines 46 - 74
}

Inside, it's pretty simple: return $this->createQueryBuilder('u'), ->andWhere('u.email LIKE :query') and bind that with ->setParameter('query') and, this is a little weird, '%'.$query.'%'.

Finish with ->setMaxResults($limit), ->getQuery() and ->getResult().

... lines 1 - 36
public function findAllMatching(string $query, int $limit = 5)
{
return $this->createQueryBuilder('u')
->andWhere('u.email LIKE :query')
->setParameter('query', '%'.$query.'%')
->setMaxResults($limit)
->getQuery()
->getResult();
}
... lines 46 - 76

Done! Unless I've totally mucked things up, I think we should have a working autocomplete setup! Refresh to get the new JavaScript, type "spac" and... woohoo! Only 5 results! Let's get the web debug toolbar out of the way. I love it!

Next: there's one other important method you can override in your custom form field type class to control how it renders. We'll use it to absolutely make sure our autocomplete field has the HTML attributes it needs, even if we override the attr option when using the field.

Leave a comment!

5
Login or Register to join the conversation
Default user avatar

is there any way how to update the results in the table while typing in the search bar with ajax and symfony

Reply

Hey @dave!

In general, for a search bar, I would not use Symfony's Form component. It's just too simple of a situation (only one field and no validation errors), so creating a normal HTML input field and handling it manually in a controller will give you a lot of flexibility.

So, to answer your question, of course! The *how* depends entirely on how you're building your frontend. If you were using something like React to build the results in your table, then each time you type, you'd send an AJAX request, that AJAX request would return the current results based on that search in JSON, and in React, you would re-render using those results. There is also the DataTable JavaScript library, which would work in a very similar way: https://datatables.net/.

In all cases, in Symfony, you have an endpoint that is able to read the current search value and return the results based on that value. How you render that on the front-end depends on the JavaScript library.

Cheers!

Reply
Mike P. Avatar
Mike P. Avatar Mike P. | weaverryan | posted 4 years ago | edited

I have a similar question, in this example we override the configureDefaults to add a custom data- class with the url we want to submit the Ajax call to.

In the example of a global search bar (accessible from every page), like you said Ryan, it is a simple html form element, Symfony Forms is not used.

How would you pass the autocomplete url when Forms is not used?
I currently have this snippet inside my base.html.twig:


<script>
                let $searchbar_suggestion_url = "{{ path('searchbar_suggestions')|escape('js') }}";
            </script>

It feels a bit "unclean", because of its location (base.html.twig).

Reply

Hey Mike P.

Well, you could just use Symfony forms for that, or add a data-url HTML attribute to your form and then do the same thing Ryan did.

Cheers!

Reply
Default user avatar

thanks Ryan

Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.1.3",
        "ext-iconv": "*",
        "composer/package-versions-deprecated": "^1.11", // 1.11.99
        "knplabs/knp-markdown-bundle": "^1.7", // 1.7.0
        "knplabs/knp-paginator-bundle": "^2.7", // v2.8.0
        "knplabs/knp-time-bundle": "^1.8", // 1.8.0
        "nexylan/slack-bundle": "^2.0,<2.2.0", // v2.0.0
        "php-http/guzzle6-adapter": "^1.1", // v1.1.1
        "sensio/framework-extra-bundle": "^5.1", // v5.2.1
        "stof/doctrine-extensions-bundle": "^1.3", // v1.3.0
        "symfony/asset": "^4.0", // v4.1.6
        "symfony/console": "^4.0", // v4.1.6
        "symfony/flex": "^1.0", // v1.17.6
        "symfony/form": "^4.0", // v4.1.6
        "symfony/framework-bundle": "^4.0", // v4.1.6
        "symfony/orm-pack": "^1.0", // v1.0.6
        "symfony/security-bundle": "^4.0", // v4.1.6
        "symfony/serializer-pack": "^1.0", // v1.0.1
        "symfony/twig-bundle": "^4.0", // v4.1.6
        "symfony/validator": "^4.0", // v4.1.6
        "symfony/web-server-bundle": "^4.0", // v4.1.6
        "symfony/yaml": "^4.0", // v4.1.6
        "twig/extensions": "^1.5" // v1.5.2
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.0", // 3.0.2
        "easycorp/easy-log-handler": "^1.0.2", // v1.0.7
        "fzaninotto/faker": "^1.7", // v1.8.0
        "symfony/debug-bundle": "^3.3|^4.0", // v4.1.6
        "symfony/dotenv": "^4.0", // v4.1.6
        "symfony/maker-bundle": "^1.0", // v1.8.0
        "symfony/monolog-bundle": "^3.0", // v3.3.0
        "symfony/phpunit-bridge": "^3.3|^4.0", // v4.1.6
        "symfony/profiler-pack": "^1.0", // v1.0.3
        "symfony/var-dumper": "^3.3|^4.0" // v4.1.6
    }
}
userVoice