Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Refactoring Autocomplete JS & CSS

Video not working?

It looks like your browser may not support the H264 codec. If you're using Linux, try a different browser or try installing the gstreamer0.10-ffmpeg gstreamer0.10-plugins-good packages.

Thanks! This saves us from needing to use Flash or encode videos in multiple formats. And that let's us get back to making more videos :). But as always, please feel free to message us.

We still have work to do to get the new.html.twig template working:

... lines 1 - 2
{% block javascripts %}
... lines 4 - 5
<script src="https://cdn.jsdelivr.net/autocomplete.js/0/autocomplete.jquery.min.js"></script>
<script src="{{ asset('js/algolia-autocomplete.js') }}"></script>
... line 8
{% endblock %}
... lines 10 - 25

we have a script tag for this external autocomplete library and one for our own public/js/algolia-autocomplete.js file... which is our last JavaScript file in the public/ directory! Woo!

$(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.users);
});
},
displayKey: 'email',
debounce: 500 // only request every 1/2 second
}
])
});
});

This holds code that adds auto-completion... on this author box... which, yes, is totally broken.

Installing the Autocomplete Library

To start, remove the CDN link to this autocomplete library:

... lines 1 - 2
{% block javascripts %}
... lines 4 - 5
<script src="https://cdn.jsdelivr.net/autocomplete.js/0/autocomplete.jquery.min.js"></script>
... lines 7 - 8
{% endblock %}
... lines 10 - 25

And, at your terminal, install it properly!

yarn add autocomplete.js --dev

Organizing our Autocomplete into a Component

Next, you know the drill, take the algolia-autocomplete.js file and move it into the assets/js/ directory. But I'm not going to make this a new entry point. We could do that, but really, we already have an entry file that's included on this page: admin_article_form:

... lines 1 - 2
{% block javascripts %}
... lines 4 - 7
{{ encore_entry_script_tags('admin_article_form') }}
{% endblock %}
... lines 10 - 25

So really, admin_article_form.js should probably just use the code from algolia-autocomplete.js.

So, move that file into the components/ directory... which is kind of meant for reusable modules. And... well, this isn't really written like a re-usable module yet because it just executes code instead or returning something, like a function. But, we'll work on that later.

Let's also take the algolia-autocomplete.css file and move that all the way up here into assets/css/. And just because we can, I'll make it an SCSS file!

Okay! Back in admin_article_form.js, let's bring in this code: import './components/algolia-autocomplete':

... lines 1 - 4
import './components/algolia-autocomplete';
... lines 6 - 157

We don't need an import from yet... because that file doesn't actually export anything. For the CSS: import '../css/algolia-autocomplete.scss':

... lines 1 - 4
import './components/algolia-autocomplete';
import '../css/algolia-autocomplete.scss';
... lines 7 - 157

Back in new.html.twig, the great thing is, we don't need to import this CSS file anymore or any of these script files. This is really how we want our templates to look: a single a call to {{ encore_entry_script_tags() }} and a single call to {{ encore_entry_link_tags() }}:

... lines 1 - 2
{% block javascripts %}
{{ parent() }}
{{ encore_entry_script_tags('admin_article_form') }}
{% endblock %}
{% block stylesheets %}
{{ parent() }}
{{ encore_entry_link_tags('admin_article_form') }}
{% endblock %}
... lines 14 - 22

So if we refresh right now, not surprisingly, it still won't work! And it's our favorite error!

$ is undefined

from algolia-autocomplete.js. Yes, this is the error I see when I close my eyes at night.

Using the autocomplete.js Library

Let's get to work. Of course, we are referencing $. So, import $ from 'jquery':

import $ from 'jquery';
... lines 2 - 23

We're also using the autocomplete library in here. No problem: import autocomplete from 'autocomplete.js':

import $ from 'jquery';
import autocomplete from 'autocomplete.js';
... lines 3 - 23

Wait... that's not quite right. This autocomplete.js library is a standalone JavaScript library that can be used with anything - jQuery, React, whatever. But... our existing code isn't using the "standalone" version of the library. It's using a jQuery plugin - this .autocomplete() function - that comes with that package:

... lines 1 - 3
$(document).ready(function() {
$('.js-user-autocomplete').each(function() {
... lines 6 - 7
$(this).autocomplete({hint: false}, [
... lines 9 - 19
])
});
});

So, we could refactor our code down here to use the, kind of, official way of using this library - independent of jQuery. But... that's the easy way out! Let's see if we can get this to work as a jQuery plugin.

Finding and Using the jQuery Plugin

I'll hold Command or Control and click into autocomplete.js. Then double-click the directory to zoom us there. The "main" file is this index.js at the root of the directory. But if you look in dist/, hey! autocomplete.jquery.js! That's what we were including before via the <script> tag!

So instead of importing the main file, let's import autocomplete.js/dist/autocomplete.jquery:

import $ from 'jquery';
import 'autocomplete.js/dist/autocomplete.jquery';
... lines 3 - 23

And remember, we don't use import from with jQuery plugins... because they don't return anything: they modify the jQuery object.

Ok, I think we're great and I think we're ready. Move over, refresh and... huh:

jQuery is not defined

Notice it doesn't say "$ is not defined": it says "jQuery is not defined"... and it's coming from autocomplete.jquery.js! It's coming from the third party package!

This... is tricky. Plain and simple, that file is written incorrectly. Yea, it only works if jQuery is a global variable! And in Webpack... it's not! Let's talk more about this and fix it with some black magic, next.

Leave a comment!

4
Login or Register to join the conversation
Default user avatar
Default user avatar Anton Bagdatyev | posted 2 years ago

I have a question regarding JS/jQuery plugins written correctly (like bootstrap) and "badly" (like autocomplete.js): I checked out the bootstrap source code and found out (as I understood) that bootstrap uses a getjQuery() util function to get jQuery and attach plugin functionality to it:


// In file .../alert.js
import {
getjQuery, // <--- Imports getjQuery
TRANSITION_END,
emulateTransitionEnd,
getElementFromSelector,
getTransitionDurationFromElement
} from './util/index'
import Data from './dom/data'
import EventHandler from './dom/event-handler'
import SelectorEngine from './dom/selector-engine'

// ... And uses it to define the plugin:

const $ = getjQuery()

/**
* ------------------------------------------------------------------------
* jQuery
* ------------------------------------------------------------------------
* add .alert to jQuery only if jQuery is present
*/

/* istanbul ignore if */
if ($) {
const JQUERY_NO_CONFLICT = $.fn[NAME]
$.fn[NAME] = Alert.jQueryInterface
$.fn[NAME].Constructor = Alert
$.fn[NAME].noConflict = () => {
$.fn[NAME] = JQUERY_NO_CONFLICT
return Alert.jQueryInterface
}
}

// ...

// ----------------------------------------

// In file .../util/index.js
const getjQuery = () => {
const { jQuery } = window

if (jQuery && !document.body.hasAttribute('data-no-jquery')) {
return jQuery
}

return null
}

But here you clearly see that getjQuery only checks for jQuery to be available on window...
How does then the final transpiled/built bootstrap (the one we import in our code using import 'bootstrap', i.e. "dist/js/bootstrap.js")
know that it has to attach the jQuery plugin to the "local" jQuery peer dependency we installed with yarn add jquery --dev if the bootstrap's source only uses jQuery through the getjQuery() util function and therefore through window? Hope I correctly formulated my question.

I mean, I have written a jQuery plugin called jquery-multiselect-checkbox (you can check it out here: https://github.com/tonix-tu..., and there in my index.js source file I explicitly do import $ from 'jquery' (which I marked as a peer dep in package.json) and I also check for window.jQuery and attach my plugin to both if available (if the imported $ is different from window.$, i.e. $ !== window.jQuery). But maybe this is not the way to do it when authoring libraries. I don't know...

Reply

Hey Anton Bagdatyev

That's a good question :)
I would recommend you to check how other Jquery plugins do it. What I've seen is that they check for a couple of things, like for example this


if (typeof module === 'object' && module.exports) {
// Node/CommonJS
module.exports = factory(require('jquery'));
}

That's checking if the code is being executed in a Node.js environment. I hope this helps a bit

Cheers!

Reply
Default user avatar
Default user avatar Anton Bagdatyev | MolloKhan | posted 2 years ago

Thank you for you reply, Diego!

Yes, what you say is correct, but your example is the final dist/bundle code generated by Webpack.

Whereas, I was thinking about the way plugin authors import jQuery in their sources before they modify $ so that it works in all environments (UMD, i.e. CommonJS, AMD as well as global/window object)...

I think you agree with me that library authors won't write code like this anymore nowadays:


(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports, require('jquery'), require('popper.js')) :
typeof define === 'function' && define.amd ? define(['exports', 'jquery', 'popper.js'], factory) :
(global = global || self, factory(global.bootstrap = {}, global.jQuery, global.Popper));
}(this, (function (exports, $, Popper) { 'use strict';
...

That's the result of Webpack/Rollup's transpiling process.

I just checked the source code of Bootstrap again and I found out that I was looking at the wrong version. I was looking at the master branch, whereas this Webpack Encore project uses version 4.4.1.

Indeed, if you look at the sources of that tag, you will see code like this (e.g. https://github.com/twbs/boo...


...
import $ from 'jquery'

...

$.fn[NAME] = Alert._jQueryInterface
$.fn[NAME].Constructor = Alert
$.fn[NAME].noConflict = () => {
$.fn[NAME] = JQUERY_NO_CONFLICT
return Alert._jQueryInterface
}

export default Alert

They import jQuery and just use it, so I guess that's enough when using peer dependencies as Webpack (in their case Rollup) takes care of the rest (but have to play a bit with my jQuery plugin in order to be sure).

Otherwise I don't know which kind of black magic they are using yet.

Thanks!

Reply

> so I guess that's enough when using peer dependencies as Webpack

As far as I know, yes, Webpack takes care of the rest. You just have to import the dependencies you want to work with and that would be it

Reply
Cat in space

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

This tutorial works great with Symfony5!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.1.3",
        "ext-iconv": "*",
        "aws/aws-sdk-php": "^3.87", // 3.91.4
        "composer/package-versions-deprecated": "^1.11", // 1.11.99
        "knplabs/knp-markdown-bundle": "^1.7", // 1.7.1
        "knplabs/knp-paginator-bundle": "^2.7", // v2.8.0
        "knplabs/knp-time-bundle": "^1.8", // 1.9.0
        "league/flysystem-aws-s3-v3": "^1.0", // 1.0.22
        "league/flysystem-cached-adapter": "^1.0", // 1.0.9
        "liip/imagine-bundle": "^2.1", // 2.1.0
        "nexylan/slack-bundle": "^2.0,<2.2.0", // v2.1.0
        "oneup/flysystem-bundle": "^3.0", // 3.0.3
        "php-http/guzzle6-adapter": "^1.1", // v1.1.1
        "sensio/framework-extra-bundle": "^5.1", // v5.3.1
        "stof/doctrine-extensions-bundle": "^1.3", // v1.3.0
        "symfony/asset": "^4.0", // v4.2.5
        "symfony/console": "^4.0", // v4.2.5
        "symfony/flex": "^1.9", // v1.17.6
        "symfony/form": "^4.0", // v4.2.5
        "symfony/framework-bundle": "^4.0", // v4.2.5
        "symfony/orm-pack": "^1.0", // v1.0.6
        "symfony/security-bundle": "^4.0", // v4.2.5
        "symfony/serializer-pack": "^1.0", // v1.0.2
        "symfony/twig-bundle": "^4.0", // v4.2.5
        "symfony/validator": "^4.0", // v4.2.5
        "symfony/web-server-bundle": "^4.0", // v4.2.5
        "symfony/webpack-encore-bundle": "^1.4", // v1.5.0
        "symfony/yaml": "^4.0", // v4.2.5
        "twig/extensions": "^1.5" // v1.5.4
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.0", // 3.1.0
        "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.2.5
        "symfony/dotenv": "^4.0", // v4.2.5
        "symfony/maker-bundle": "^1.0", // v1.11.5
        "symfony/monolog-bundle": "^3.0", // v3.3.1
        "symfony/phpunit-bridge": "^3.3|^4.0", // v4.2.5
        "symfony/profiler-pack": "^1.0", // v1.0.4
        "symfony/var-dumper": "^3.3|^4.0" // v4.2.5
    }
}

What JavaScript libraries does this tutorial use?

// package.json
{
    "devDependencies": {
        "@symfony/webpack-encore": "^0.27.0", // 0.27.0
        "autocomplete.js": "^0.36.0",
        "autoprefixer": "^9.5.1", // 9.5.1
        "bootstrap": "^4.3.1", // 4.3.1
        "core-js": "^3.0.0", // 3.0.1
        "dropzone": "^5.5.1", // 5.5.1
        "font-awesome": "^4.7.0", // 4.7.0
        "jquery": "^3.4.0", // 3.4.0
        "popper.js": "^1.15.0",
        "postcss-loader": "^3.0.0", // 3.0.0
        "sass": "^1.29.0", // 1.29.0
        "sass-loader": "^7.0.1", // 7.3.1
        "sortablejs": "^1.8.4", // 1.8.4
        "webpack-notifier": "^1.6.0" // 1.7.0
    }
}