Andrew Welch · Insights · #autocomplete #elementapi #vuejs

Published , updated · 5 min read ·


For more tools, technologies, and techniques, check out the devMode.fm podcast!

Autocomplete Search with the Element API & VueJS

Here’s how to use Craft CMS’s Ele­ment API and Vue­JS to cre­ate a fast, func­tion­al auto­com­plete search feature

Autocomplete Search

Once you start adding a bit of con­tent to a web­site, just hav­ing a sim­ple nav­i­ga­tion for find­ing that con­tent becomes insuf­fi­cient. Espe­cial­ly if that con­tent has ref­er­ence mate­r­i­al in it that peo­ple might want to refer to on a reg­u­lar basis.

So I decid­ed to imple­ment a search func­tion on this web­site, specif­i­cal­ly some­thing that will search the blog entries.

Peo­ple have become quite used to just typ­ing what they are look­ing for into Google, and let­ting it do the rest.

But just any search won’t do; we want autocomplete or typeahead search functionality, so that a few keystrokes will find what people are looking for.

Since we’re already using Vue­JS on this web­site, as per the Using Vue­JS 2.0 with Craft CMS arti­cle, we’re absolute­ly going to lever­age Vue for our search.

While I’m not opposed to writ­ing my own Vue Com­po­nent to do this, a big rea­son to use a frame­work like Vue is that you can drop in com­po­nents that oth­er peo­ple have made already, and use them.

I found a Vue Com­po­nent called vue2-auto­com­plete that seemed to fit the bill, but it need­ed some minor mod­i­fi­ca­tions to work just the way I want­ed it to with Craft CMS, so I forked the repo, made my changes, and away we go (yes, a Pull Request will be forthcoming).

If you’re inter­est­ed in learn­ing how to build a Vue Com­po­nent from scratch to do auto­com­plete search, the arti­cle Build­ing an Awe­some Reusable Auto­com­plete-Input Com­po­nent in Vue 2.1 is a good one.

Me, I just want­ed to get this done.

Link Autocomplete Search, Here We Come!

So before we get into the code, let’s have a look at the auto­com­plete search on this site to see how it works:

Autocomplete Search Example

Auto­com­plete Search UX

If you click on the ham­burg­er menu, you’ll see a fair­ly sparse nav­i­ga­tion appear that now includes a search field. Type a let­ter or two, and it’ll find you blog arti­cles on this site that match what you’ve typed.

Under the hood, it’s a Vue Com­po­nent that han­dles your input, then issues an AJAX call to our Ele­ment API end­point with what you’ve typed. Our Ele­ment API trans­former finds arti­cles you might be look­ing for, and returns the data as JSON to the Vue Component.

Then the Vue Com­po­nent auto­mat­i­cal­ly ren­ders the drop­down menu of search results, and away you go. It even sup­ports the arrow keys and return/​enter key for navigation.

Link Making It Go

So the first thing we need to do in order to make this all work is to add the forked Vue Com­po­nent to our package.json as per the A Bet­ter package.json for the Fron­tend arti­cle. Since this isn’t pub­lished on npmjs​.com as a node pack­age, we can just add this to our dependencies:

"vue2-autocomplete": "git://github.com/nystudio107/vue2-autocomplete.git#master",

This just tells npm or yarn hey, grab this pack­age from this GitHub URL”. If we were using the vue2-autocomplete com­po­nent with­out mod­i­fi­ca­tion, we would just spec­i­fy the pack­age as normal.

The next thing we need to do is cre­ate anoth­er Ele­ment API end­point; if you haven’t read the Lazy Load­ing with the Ele­ment API & Vue­JS arti­cle yet, check it out for a more in-depth dis­cus­sion of this:

return [
    'endpoints' => [
        'api/search' => [
            'elementType' => 'Entry',
            'paginate' => false,
            'criteria' => [
                'section' => 'blog',
                'limit' => 9,
                'search' => (craft()->request->getParam('q')) ? 'title:'.'*'.craft()->request->getParam('q').'*'.' OR ' . 'blogSummary:'.'*'.craft()->request->getParam('q').'*' : ''
                ],
            'transformer' => function(EntryModel $entry) {
                $categories = [];
                foreach ($entry->blogCategory as $cat)
                    $categories[] = $cat->title;
                $tags = [];
                foreach ($entry->blogTags as $tag)
                    $tags[] = $tag->title;
                return [
                    'title' => $entry->title,
                    'url' => $entry->url,
                    'searchUrl' => '/search?q=' . craft()->request->getParam('q'),
                    'blogSummary' => $entry->blogSummary,
                    'blogCategory' => $categories,
                    'blogTags' => $tags,
                ];
            },
        ],

Our Ele­ment API end­point URI is /api/search and the vue2-autocomplete com­po­nent pass­es in the text we’re search­ing for in the query string like so: ?q=text. So a full URL might look some­thing like like this:

https://nystudio107.com/api/search?q=semantic

The criteria array is essen­tial­ly just an Ele­ment­Cri­te­ri­aMod­el in para­me­ter­ized array form. So we can pass in the section we’re inter­est­ed in, limit the num­ber of entries returned, and even use search to find just what we’re look­ing for.

I orig­i­nal­ly had it search­ing the entire entry for what the user typed, but it felt a lit­tle less use­ful than just search­ing the title and blogSummary fields. So I used the Search Syn­tax to lim­it my search­es to wild­card search­es of those fields.

The (pret­ti­fied) JSON response to this query would be:

{
  "data": [
    {
      "title": "JSON-LD, Structured Data and Erotica",
      "url": "https:\/\/nystudio107.com\/blog\/json-ld-structured-data-and-erotica",
      "searchUrl": "/search?q=json",
      "blogSummary": "JSON-LD Structured Data is a big deal for the \"Semantic Web.\" Google cares about it. Apple cares about it. You should, too.",
      "blogCategory": [
        "Insights"
      ],
      "blogTags": [
        "json-ld",
        "structured-data",
        "SEO"
      ]
    }
  ]
}

The way the vue2-autocomplete com­po­nent is writ­ten, it just pass­es in your query, and the heavy lift­ing of deter­min­ing what match­es it is up to your Ele­ment API endpoint.

Then it sets the JSON response to a prop­er­ty in our Vue Com­po­nent, and the DOM is auto­mat­i­cal­ly re-ren­dered to reflect the data. Neat.

But before this can work, we’ll need to add in the autocomplete com­po­nent HTML for our search field:

<autocomplete
    id="searchbox"
    url="/api/search"
    anchor="title"
    label="blogSummary"
    placeholder="search"
    min="1"
    :on-select="itemSelected"
    :process="processJsonData"
    >
</autocomplete>

As you can see, we’re pass­ing in the id and url to our Vue Com­po­nent as HTML attrib­ut­es, that should feel pret­ty com­fort­able. But there are some oth­er attrib­ut­es being passed in that could use a bit of explanation.

  • anchor is what is dis­played as the title of a search result, set to the title data from our JSON
  • label is the small­er, more descrip­tive text under­neath the title of a search result, set to the blogSummary data from our JSON
  • :on-select is just a short­cut for v-bind:on-select, which binds our onSelect prop­er­ty to the method itemSelected()
  • :process is just a short­cut for v-bind:process, which binds our process prop­er­ty to the method processJsonData()

vue2-autocomplete pro­vides a num­ber of hooks that get called when­ev­er var­i­ous things hap­pen; this gives it great flex­i­bil­i­ty. So for instance, when some­one selects a search result from the list by click­ing on it or hit­ting the return/​enter key, the method bound to the onSelect prop­er­ty is called (itemSelected() in this case) with the data for the item they selected:

itemSelected: function(data) {
      window.location.href = data.url;
    },

Sim­i­lar­ly, once the JSON has been retrieved from our Ele­ment API end­point, the method bound to the process prop­er­ty is called, to allow it to process the JSON data. This is use­ful, because the JSON returned by the Ele­ment API has top-lev­el keys of data & meta, but the vue2-autocomplete com­po­nent isn’t expect­ing this. So we can fix it in our processJsonLd() method:

processJsonData: function(json) {
        return json.data;
    }

And that’s real­ly all there is to it. I’ll present the full code below, so that it makes sense in context.

But first, let’s talk about the down­side of this approach. Every sin­gle time a per­son types a key­stroke, we fire off an XML­HttpRe­quest that caus­es Craft to spin up, per­form a data­base query, and return the results as JSON.

This is some­what mit­i­gat­ed by the fact that our Ser­vice Work­er caches requests, so repeat­ed requests will be quite quick. Check out the Ser­vice­Work­ers and Offline Brows­ing arti­cle for details on that.

If this were ever to become a high-traf­fic web­site, I would instead just cache the JSON response for all of the entries in my blog chan­nel, and then per­form a sim­ple array search. I would do this client-side once, to keep the net­work chat­ter to a minimum.

You could even issue an XML­HttpRe­quest for the full JSON response when some­one mous­es over the search field, antic­i­pat­ing that they are like­ly going to want to search for some­thing. That way you don’t load the JSON response on every page for no rea­son, but rather when you’re like­ly to need it.

Still, for small­er-scale projects like this, it works fine, the results are nice, and it’s fun to nar­rate how things are imple­ment­ed on this web­site in a blog on this website.

Here’s the full code; for a dis­cus­sion of some of the oth­er bits of it, please see the Using Vue­JS 2.0 with Craft CMS & Load­JS as a Light­weight JavaScript Loader articles.

<script>
    // define a dependency bundle
    loadjs(
        [
            '{{ baseUrl }}js/vue.min{{staticAssetsVersion}}.js',
        ],
        'vue'
    );
    loadjs(
        [
            '{{ baseUrl }}js/vue2-autocomplete.min{{staticAssetsVersion}}.js',
        ],
        'vue-autocomplete'
    );
    loadjs.ready(['vue', 'vue-autocomplete'], {
        success: function() {
            new Vue({
                el: '#nav-menu',
                components: {
                  autocomplete: Vue2Autocomplete
                },
                delimiters: ['${', '}'],
                data: {
                    menuOpen: false,
                },
                methods: {
                    prerenderLink: function(e) {
                        var head = document.getElementsByTagName("head")[0];
                        var refs = head.childNodes;
                        ref = refs[ refs.length - 1];

                        var elements = head.getElementsByTagName("link");
                        Array.prototype.forEach.call(elements, function(el, i) {
                            if (("rel" in el) && (el.rel === "prerender"))
                                el.parentNode.removeChild(el);
                        });

                        var prerenderTag = document.createElement("link");
                        prerenderTag.rel = "prerender";
                        prerenderTag.href = e.currentTarget.href;
                        ref.parentNode.insertBefore(prerenderTag,  ref);
                    },
                    toggle: function() {
                        this.menuOpen = !this.menuOpen;
                    },
                    itemSelected: function(data) {
                      ga('send', 'pageview', data.searchUrl);
                      window.location.href = data.url;
                    },
                    processJsonData: function(json) {
                        return json.data;
                    }
                },
            });
        }
    });
</script>

Link Google Site Search Analytics

You might notice that in our elementapi.php we’re return­ing a para­me­ter called searchUrl in the JSON data. This is so that we can lever­age Google Site Search Ana­lyt­ics, and get a report­ing of the search terms peo­ple use when search­ing for things on our website.

We then send a vir­tu­al pageview when some­one has select­ed some­thing from our results list via ga('send', 'pageview', data.searchUrl); to reg­is­ter the site search query with our Google Ana­lyt­ics account. 

Hap­py searching!