Andrew Welch · Insights · #autocomplete #elementapi #vuejs

Making the web better one site at a time, with a focus on performance, usability & SEO

2017.02.22 · 5 min read ·

Autocomplete Search with the Element API & VueJS

Here’s how to use Craft CMS’s Element API and VueJS to create a fast, functional autocomplete search feature

Once you start adding a bit of content to a website, just having a simple navigation for finding that content becomes insufficient. Especially if that content has reference material in it that people might want to refer to on a regular basis.

So I decided to implement a search function on this website, specifically something that will search the blog entries.

People have become quite used to just typing what they are looking for into Google, and letting 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 VueJS on this website, as per the Using VueJS 2.0 with Craft CMS article, we’re absolutely going to leverage Vue for our search.

While I’m not opposed to writing my own Vue Component to do this, a big reason to use a framework like Vue is that you can drop in components that other people have made already, and use them.

I found a Vue Component called vue2-autocomplete that seemed to fit the bill, but it needed some minor modifications to work just the way I wanted 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 interested in learning how to build a Vue Component from scratch to do autocomplete search, the article Building an Awesome Reusable Autocomplete-Input Component in Vue 2.1 is a good one.

Me, I just wanted to get this done.

Autocomplete Search, Here We Come!

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

Autocomplete Search UX

If you click on the hamburger menu, you’ll see a fairly sparse navigation appear that now includes a search field. Type a letter or two, and it’ll find you blog articles on this site that match what you’ve typed.

Under the hood, it’s a Vue Component that handles your input, then issues an AJAX call to our Element API endpoint with what you’ve typed. Our Element API transformer finds articles you might be looking for, and returns the data as JSON to the Vue Component.

Then the Vue Component automatically renders the dropdown menu of search results, and away you go. It even supports the arrow keys and return/enter key for navigation.

Making It Go

So the first thing we need to do in order to make this all work is to add the forked Vue Component to our package.json as per the A Better package.json for the Frontend article. Since this isn’t published on npmjs.com as a node package, 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 package from this GitHub URL”. If we were using the vue2-autocomplete component without modification, we would just specify the package as normal.

The next thing we need to do is create another Element API endpoint; if you haven’t read the Lazy Loading with the Element API & VueJS article yet, check it out for a more in-depth discussion 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 Element API endpoint URI is /api/search and the vue2-autocomplete component passes in the text we’re searching for in the query string like so: ?q=text. So a full URL might look something like like this:

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

The criteria array is essentially just an ElementCriteriaModel in parameterized array form. So we can pass in the section we’re interested in, limit the number of entries returned, and even use search to find just what we’re looking for.

I originally had it searching the entire entry for what the user typed, but it felt a little less useful than just searching the title and blogSummary fields. So I used the Search Syntax to limit my searches to wildcard searches of those fields.

The (prettified) 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 component is written, it just passes in your query, and the heavy lifting of determining what matches it is up to your Element API endpoint.

Then it sets the JSON response to a property in our Vue Component, and the DOM is automatically re-rendered to reflect the data. Neat.

But before this can work, we’ll need to add in the autocomplete component 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 passing in the id and url to our Vue Component as HTML attributes, that should feel pretty comfortable. But there are some other attributes being passed in that could use a bit of explanation.

  • anchor is what is displayed as the title of a search result, set to the title data from our JSON
  • label is the smaller, more descriptive text underneath the title of a search result, set to the blogSummary data from our JSON
  • :on-select is just a shortcut for v-bind:on-select, which binds our onSelect property to the method itemSelected()
  • :process is just a shortcut for v-bind:process, which binds our process property to the method processJsonData()

vue2-autocomplete provides a number of hooks that get called whenever various things happen; this gives it great flexibility. So for instance, when someone selects a search result from the list by clicking on it or hitting the return/enter key, the method bound to the onSelect property is called (itemSelected() in this case) with the data for the item they selected:

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

Similarly, once the JSON has been retrieved from our Element API endpoint, the method bound to the process property is called, to allow it to process the JSON data. This is useful, because the JSON returned by the Element API has top-level keys of data & meta, but the vue2-autocomplete component isn’t expecting this. So we can fix it in our processJsonLd() method:

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

And that’s really 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 downside of this approach. Every single time a person types a keystroke, we fire off an XMLHttpRequest that causes Craft to spin up, perform a database query, and return the results as JSON.

This is somewhat mitigated by the fact that our Service Worker caches requests, so repeated requests will be quite quick. Check out the ServiceWorkers and Offline Browsing article for details on that.

If this were ever to become a high-traffic website, I would instead just cache the JSON response for all of the entries in my blog channel, and then perform a simple array search. I would do this client-side once, to keep the network chatter to a minimum.

You could even issue an XMLHttpRequest for the full JSON response when someone mouses over the search field, anticipating that they are likely going to want to search for something. That way you don’t load the JSON response on every page for no reason, but rather when you’re likely to need it.

Still, for smaller-scale projects like this, it works fine, the results are nice, and it’s fun to narrate how things are implemented on this website in a blog on this website.

Here’s the full code; for a discussion of some of the other bits of it, please see the Using VueJS 2.0 with Craft CMS & LoadJS as a Lightweight 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>

Google Site Search Analytics

You might notice that in our elementapi.php we’re returning a parameter called searchUrl in the JSON data. This is so that we can leverage Google Site Search Analytics, and get a reporting of the search terms people use when searching for things on our website.

We then send a virtual pageview when someone has selected something from our results list via ga('send', 'pageview', data.searchUrl); to register the site search query with our Google Analytics account. 

Happy searching!

Insights · 2017.07.23

Making Websites Better through Accessibility

#frontend #accessibility #best-practices

Insights · 2017.06.16

Static Page Caching with Craft CMS

#devops #performance #craftcms

Investigations · 2017.06.11

The Case of the Missing PHP Session

#devops #php #craftcms

Insights · 2017.05.24

So You Wanna Make a Craft 3 Plugin?

#homestead #PhpStorm #craft-3

Insights · 2017.05.10

A/B Split Testing with Nginx & Craft CMS

#devops #frontend #split testing

Insights · 2017.05.06

Mobile Testing & Local Dev Sharing with Homestead

#devops #homestead #sharing

Insights · 2017.05.05

Simple Static Asset Versioning in Craft CMS

#frontend #devops #craftcms

· 2017.04.19

Tags Gone Wild! Managing Tag Managers

#frontend #devops #performance

Insights · 2017.03.31

Local Development with Vagrant / Homestead

#devops #frontend #homestead