Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Autocomplete JavaScript

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

From a backend perspective, the custom field is done! When the user submits a string email address, the data transformer turns that into the proper User object, with built-in validation.

But from a frontend perspective, it could use some help. It would be way more awesome if this field had some cool JavaScript auto-completion magic where it suggested valid emails as I typed. So... let's do it!

Google for "Algolia autocomplete". There are a lot of autocomplete libraries, and this one is pretty nice. Click into their documentation and then to the GitHub page for autocomplete.js.

Many of you might know that Symfony comes with a great a JavaScript tool called Webpack Encore, which helps you create organized JavaScript and build it all into compiled files. We have not been using Encore in this tutorial yet. So I'm going to keep things simple and continue without it. Don't worry: the most important part of what we're about to do is the same no matter what: it's how you connect custom JavaScript to your form fields.

Adding the autocomplete.js JavaScript

Copy the script tag for jQuery, open templates/article_admin/edit.html.twig and override {% block javascripts %} and {% endblock %}. Call the {{ parent() }} function to keep rendering the parent JavaScript. Then paste in that new <script> tag.

... lines 1 - 2
{% block javascripts %}
{{ parent() }}
<script src="https://cdn.jsdelivr.net/autocomplete.js/0/autocomplete.jquery.min.js"></script>
... line 7
{% endblock %}
... lines 9 - 23

Yes, we are also going to need to do this in the new template. We'll take care of that in a little bit.

Now, if you scroll down a little on their docs... there it is! This page has some CSS that helps make all of this look good. Copy that, go to the public/css directory, and create a new file: algolia-autocomplete.css. Paste this there.

Include this file in our template as well: override {% block stylesheets %} and {% endblock %}. This time add a <link> tag that points to that file: algolia-autocomplete.css. Oh, and don't forget the parent() call - I'll add that in a second.

... lines 1 - 9
{% block stylesheets %}
{{ parent() }}
<link rel="stylesheet" href="{{ asset('css/algolia-autocomplete.css') }}">
{% endblock %}
... lines 15 - 23

Finally, for the custom JavaScript logic, in the js/ directory, create a new file called algolia-autocomplete.js. Before I fill anything in here, include that in the template: a <script> tag pointing to js/algolia-autocomplete.js.

... lines 1 - 2
{% block javascripts %}
... lines 4 - 6
<script src="{{ asset('js/algolia-autocomplete.js') }}"></script>
{% endblock %}
... lines 9 - 23

Implementing autocomplete.js

Initial setup done! Head back to their documentation to find where it talks about how to use this with jQuery. It looks kinda simple: select an element, call .autcomplete() on it, then... pass a ton of options that tell it how to fetch and process the autocomplete data.

Cool! Let's do something similar! I'll start with the document.ready() block from jQuery just to make sure the DOM is fully loaded. Now: here is the key moment: how can we write JavaScript that can connect to our custom field? Should we select it by the id? Something else?

I like to select with a class. Find all elements with, how about, some .js-user-autocomplete class. Nothing has this class yet, but our field will soon. Call .autocomplete() on this, pass it that same hint: false and then an array. This looks a bit complex: add a JavaScript object with a source option set to a function() that receives a query argument and a callback cb argument.

Basically, as we're typing in the text field, the library will call this function and pass whatever we've entered into the text box so far as the query argument. Our job is to determine which results match this "query" text and pass those back by calling the cb function.

To start... let's hardcode something and see if it works! Call cb() and pass it an array where each entry is an object with a value key... because that's how the library wants the data to be structured by default.

$(document).ready(function() {
$('.js-user-autocomplete').autocomplete({hint: false}, [
{
source: function(query, cb) {
cb([
{value: 'foo'},
{value: 'bar'}
])
}
}
]);
});

Thanks to my imaginative code, no matter what we type, foo and bar should be suggested.

Adding the js- Class to the Field

And... we're almost... sorta done! In order for this to be applied to our field, all we need to do is add this class to the author field. No problem! Copy the class name and open UserSelectTextType. Here, we can set a default value for the attr option to an array with class set to js-user-autocomplete.

... lines 1 - 11
class UserSelectTextType extends AbstractType
{
... lines 14 - 33
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
... lines 37 - 40
'attr' => [
'class' => 'js-user-autocomplete'
]
]);
}
}

Field Options vs View Variables

Up until now, if we've wanted to add a class attribute, we've done it from inside of our Twig template. For example, open security/register.html.twig. For the form start tag, we're passing an attr variable with a class key. Or, for the fields, we're adding a placeholder attribute.

attr is one of a few things that can be passed either as a view variable or also as a field option. But, I want to be clear: options and variables are two different things. Go back and open the profiler. Click on, how about, the author field. We know that there is a set of options that we can pass to the field from inside the form class. And then, when you're rendering in your template, there is a different set of view variables. These are two different concepts. However, there is some overlap, like attr.

Behind the scenes, when you pass the attr option, that simply becomes the default value for the attr view variable. The attr option, just like the label and help options - exists just for the added convenience of being able to set these in your form class or in your template.

Anyways, thanks to the code in UserSelectTextType, our field should have this class. Let's try it! Close the profiler, refresh and... ah! I killed my page! The CSS is gone! I always do that! Go back to the template and add the missing parent() call: I don't want to completely replace the CSS from our layout.

Ok, try it again. Much better. And when we type into the field... yes! We get foo and bar no matter what we type. Awesome!

Next, hey: I like foo and bar as much as the next programmer. But we should probably make an AJAX call to fetch a true list of matching email addresses.

Leave a comment!

22
Login or Register to join the conversation
Bippo Avatar
Bippo Avatar Bippo | posted 11 months ago | edited

isnt there a nicer way to inject css and script into the view? e.g. instead of MANUALLY injecting, isnt there a way to put the dependencies somewhere else? because if you have 15 dependencies in a view, this can become ugly quickly. In laravel there is the @once notation that can be used potentially. As well components in general

Reply

Hey Bippo,

You can use Twig inheritance, in a base template you add a CSS and Script block with all of your dependencies, and then, any template that requires them can just inherit from that template. Or, you can create a twig file including the deps, and add it to your template by using the include function

Cheers!

Reply
Cyril Avatar

Hi !
Is there a way to attach autocomplete.js globally for all the text inputs of a page (with a class for example) even when they are created by JS after the page is loaded? If possible, I don't want to call an init fonction each time an input is created but make a sort of global listener. Help would be appreciated!

Reply

Hey Cyril S.!

I think we could make that happen :). In fact, our setup already almost makes this possible.

1) Give every input that you want 4 things:

A) a generic class - like js-autocomplete.
B) a data-autocomplete-url attribute, like we do already - https://symfonycasts.com/screencast/symfony-forms/autocomplete-ajax#adding-a-data-autocomplete-url-attribute
C) a data-key attribute, which is set to the "key" on the AJAX call that you want to use - it will the hardcoded .users code here https://symfonycasts.com/screencast/symfony-forms/autocomplete-ajax#codeblock-b3f8faa454
D) A data-display-key, which will replace the hardcoded displayKey: 'email' here: https://symfonycasts.com/screencast/symfony-forms/autocomplete-ajax#codeblock-b3f8faa454

2) Then the JavaScript will just need some minor updates:


$(document).ready(function() {
    $('.js-user-autocomplete').each(function() {
        var autocompleteUrl = $(this).data('autocomplete-url');
        $(this).autocomplete({hint: false}, [
            {
                source: function(query, cb) {
                    $.ajax({
                        url: autocompleteUrl+'?query='+query
                    }).then(function(data) {
                        cb(data[$(this).data('key')]);
                    });
                },
                displayKey: $(this).data('display-key'),
                debounce: 500 // only request every 1/2 second
            }
        ])
    });
});

I may have missed something, but I think that should do it! Let me know!

Cheers!

1 Reply
Cyril Avatar

Finally, I decided to use the old fashion way: to call the init autocomplete method on each new created input. Maybe is there a better way with a sort of global listener but, at least, it works. Thanks for your help.

1 Reply
Cyril Avatar

Thanks for your answer. Your code is exactly what I already had and it works perfectly with all the inputs loaded with the page. But it fails with inputs added by javascript later, as the listener doesn't listen them :-(
I wanted to make a global listener on document but I don't know which event to listen...

Reply

Hey Cyril S.!

Ah, yes, the classic problem of attaching behavior to a DOM element after it's added to the page :). Basically, you will need to do this:

Isolate all of the code inside the document(ready) into a function. Then, each time you add an element to the page, you call that function again, which will reinitialize the elements. You may also need to track which elements have already been initialized by setting some data key on the element (e.g. $(this).data('_autocomplete_initialized')) and then skipping elements with that data.

There is another solution - called a "delegate selector" https://symfonycasts.com/screencast/javascript/delegate-selectors-ftw - that almost works... but it won't in this case. You really do need to call .autocomplete() manually each time a new element is added.

If you have any questions about the proposed solution, let me know!

Cheers!

Reply
Ozornick Avatar
Ozornick Avatar Ozornick | posted 3 years ago | edited

Aahh! Set in webpack
<br />import 'autocomplete.js/dist/autocomplete.jquery.min'<br />// import 'autocomplete.js' $(...).autocomplete is not a function<br />
I did not immediately understand the reason. But my code does not want to work. Night, 2:38 =(

Reply
Irina S. Avatar
Irina S. Avatar Irina S. | Ozornick | posted 3 years ago | edited

Maybe it helps someone in future:
in app.js
import 'autocomplete.js/dist/autocomplete.jquery';
not
import 'autocomplete.js';

Reply
Farshad Avatar

Uncaught Error: Cannot find module 'autocomplete.js/dist/autocomplete.jquery'

Reply

Hey Irina,

Thank you for this tip!

Cheers!

Reply

Hey Ozornick

I believe you are hitting a known issue related to old jQuery plugins and Encore. In this chapter Ryan explains what's the problem and how to fix it https://symfonycasts.com/sc...

Cheers!

Reply
Peter T. Avatar
Peter T. Avatar Peter T. | posted 4 years ago

Hello,

i tried the sample, to show "foo" and "bar" but the javascript doesn't work and the previous user-entries appeared.
Script-tags seems to be okay. The assignment of the class also is correct.

Also checked for typos.
Is there possibly a general error I could have?
thanks in advance
peter

Reply

Hey @Peter!

Hmm. The fact that the previous user-entries appeared definitely makes me think that the JavaScript simply isn't taking effect for some reason. Do you have any JavaScript errors? The other thing I like to check when JavaScript silently doesn't work, is to make sure I'm selecting the element correctly. In this case, in algolia-autocomplete.js, between the first line (document ready) and the 2nd line (the .autocomplete() line), add this:


console.log($('.js-user-autocomplete').length);

If this says 0... then for some reason, your element is not being found on the page. If it is 1, then... well... the problem is somewhere else ;).

Let me know!

Cheers!

Reply

jumping in here just in case this helps anyone else, i got a console.log return value of 2 (would be more if my form had more than 1 field) turns out was applying the class to both the entire form and the field inside it. Fixed my issue by moving this part of code

`
'attr' => [

            'class' => 'js-user-autocomplete'
        ]

`
up to the field in builder->add (DON'T SET IN RESOLVER OPTIONS)
Console.log now returns 1 and it is only applied to the field needed and the autocomplete works like a charm!

Perhaps something has changed in the way Forms interact with this js plugin in symfony 5?
Anyway, investigate it is not a CSS class issue like mine was.. spent too long investigating javascript when it was all working well..

1 Reply

Hey jacobbullock95!

Thanks for posting! It's always nice to see other people's solutions to problems in case it helps others :).

Perhaps something has changed in the way Forms interact with this js plugin in symfony 5?

It is possible, but I'm not aware of anything. The important piece in this video is that we're creating a custom field ( UserSelectTextType) and adding the attr option to its configureOptions() method. We're not adding it in the main form class anywhere - e.g. ArticleFormType. This allows us (in ArticleFormType) to add the UserSelectTextType and not have to also pass the "attr" option - it's already the "default" value thanks to the attr option we've added to UserSelectTextType. If you did add the attr option to the configureOptions() method of ArticleFormType, it would definitely add it to the form tag. So, just put it on UserSelectTextType and nowhere else. Or, it's just as valid to NOT put it on that class, and only put it in the builder->add() section like you did.

I'm not sure if that will clear anything up for you, but hopefully it will explain what might be going on :).

Cheers!

1 Reply
Peter T. Avatar

Hi, Ryan,

Thanks for the quick answer.

I found the "error" and it was really a "general error".
NoScript was active and blocked cdn.jsdelivr.net.
A stupid mistake on my part.
I'll add a script blocker check.

But on this occasion: the courses are great!

Best regards

Peter

Reply

Sweet! Nice debugging! Now keep going! ;)

Reply
Stephan Avatar
Stephan Avatar Stephan | weaverryan | posted 3 years ago | edited

In my case, I have the same problem. But, I have neither 0 neither 1 when refreshing the page for:
console.log($('.js-user-autocomplete').length);
How can I solve this please?

Reply
Stephan Avatar
Stephan Avatar Stephan | Stephan | posted 3 years ago | edited

I found my mistake. Finally, it is just a misspelling: I wrote

{% block javascript %} instead of

{% block javascript %}

Reply
Stephan Avatar
Stephan Avatar Stephan | Stephan | posted 3 years ago | edited

Sorry, instead of {% block javascripts %}<br /> with a "s".

Reply

Hey Stephansav,

Ah, tricky misprint, difficult to notice! Glad you nailed it! And thank you for your feedback that you got it working.

Cheers!

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