This tutorial will add the feature of autocomplete as you type. In the form where a user can enter a reply to a thread, we want the application to automatically start fetching usernames to choose from when you type a string that begins with the @ symbol. There is a jQuery plugin built for this exact purpose called At.js. In addition to autocomplete of usernames, the plugin also offers emoji like characters such as smileys, coffee cups, and more. Since the reply form is actually a VueJS component, that means we’ll need to import the jQuery plugin into the component to make it work. Let’s tinker with At.js now.
Installing At.js
Before we can use the plugin, we’ll need to install it. We install at.js and jquery.caret using the yarn package manager here.
vagrant@homestead:~/Code/forumio$ yarn add at.js save vagrant@homestead:~/Code/forumio$ yarn add jquery.caret save
Import jQuery Plugin To Vue Component
In the component we want to make use of the At.js plugin, we’ll need to make use of an import statement. In the script area of the NewReply component, this is how to import those two modules.
<script>
import 'jquery.caret';
import 'at.js';
Bootup The Watcher
Since we’re now working with Javascript we’ll need to boot up the watcher.
vagrant@homestead:~/Code/forumio$ yarn run watch-poll
Now as we update the files in question, webpack does it’s job in the background recompiling and minifying on the fly.
Leveraging At.js In Vue Component
Now that the code is available for use inside of the Vue component itself, we can add some markup to make use of the auto complete functionality. We have highlighted the markup in question here.
<template>
<div>
<div v-if="signedIn">
<div class="form-group">
<textarea name="body"
id="body"
class="form-control"
placeholder="Have something to say?"
rows="5"
required
v-model="body"></textarea>
</div>
<button type="submit"
class="btn btn-default"
@click="addReply">Post
</button>
</div>
<p class="text-center" v-else>
Please <a href="/login">sign in</a> to participate in this
discussion.
</p>
</div>
</template>
<script>
import 'jquery.caret';
import 'at.js';
export default {
data() {
return {
body: ''
};
},
computed: {
signedIn() {
return window.App.signedIn;
}
},
mounted() {
jQuery('#body').atwho({
at: "@",
delay: 750,
callbacks: {
remoteFilter: function (query, callback) {
$.getJSON("/api/users", {name: query}, function (usernames) {
callback(usernames)
});
}
}
});
},
methods: {
addReply() {
axios.post(location.pathname + '/replies', {body: this.body})
.catch(error => {
flash(error.response.data, 'danger');
})
.then(({data}) => {
this.body = '';
flash('Your reply has been posted.');
this.$emit('created', data);
});
}
}
}
</script>
Let’s examine how this works. First off, we put this inside the mounted() VueJS function. This triggers after the Vue instance has been mounted, when the original target element is replaced with the Vue element.
Next, we use jQuery to target the element that we want to add autocomplete functionality to. Via jQuery(‘#body’), we are targeting the element which has the id of ‘body’. Note that the <textarea> of this Vue component has an id of ‘body’ so this is what the at.js plugin works with.
After this, the atwho() method is called. This is the main function used in the at.js library. An object is passed to the call of atwho(). The ‘at’ property is where you specify a character to make the plugin take action. In our case, the @ symbol is used. The ‘delay’ property takes a value in milliseconds.
Next up is the ‘callbacks’ property and in here are functions set for handling and rendering the data. Inside the callbacks property is the call to remoteFilter which at.js uses to fetch matching data from the server to actually complete the auto suggest feature. In fact, if we load up a threads page and start typing into the text area, if we enter an @ symbol we can see ajax requests fire off to the server.
Configure The Laravel API
The front end side of this is more or less in place. Now we need to set up an endpoint on the API so that it can respond to those ajax requests. What the endpoint will do is to search the database for potential matches to the typed text and return those potential matches to the front end. The front end then loops over the results and builds out a list of potential matches.
Adding a route
We can add this line of code to the end of the web.php routes file.
Route::get('api/users', 'ApiUsersController@index');
Add a test
Let’s add a test for this api endpoint.
- Given we have a user LilKim
- Given we have a user LilJim
- Given we have a user JoeBlow
- When a json request is made
- Then we should have 2 possible matches
function test_it_can_fetch_all_mentioned_users_starting_with_the_given_characters()
{
create('AppUser', ['name' => 'LilKim']);
create('AppUser', ['name' => 'LilJim']);
create('AppUser', ['name' => 'JoeBlow']);
$results = $this->json('GET', '/api/users', ['name' => 'Lil']);
$this->assertCount(2, $results->json());
}
Running the test gives us a ReflectionException of “Class AppHttpControllersApiUsersController does not exist”
vagrant@homestead:~/Code/forumio$ phpunit --filter test_it_can_fetch_all_mentioned_users_starting_with_the_given_characters PHPUnit 6.5.5 by Sebastian Bergmann and contributors. E 1 / 1 (100%) Time: 808 ms, Memory: 8.00MB There was 1 error: 1) TestsFeatureMentionUsersTest::test_it_can_fetch_all_mentioned_users_starting_with_the_given_characters ReflectionException: Class AppHttpControllersApiUsersController does not exist
That’s ok! All we need to do is build out a new controller. Here we go!
vagrant@homestead:~/Code/forumio$ php artisan make:controller Api/UsersController Controller created successfully.
Note how we prefix the name of the controller with the string of ‘Api’. This automatically creates a new directory for our API controllers, and puts this new UsersController in the directory for us. Now that our class is created, we can run the test once more.
vagrant@homestead:~/Code/forumio$ phpunit --filter test_it_can_fetch_all_mentioned_users_starting_with_the_given_characters PHPUnit 6.5.5 by Sebastian Bergmann and contributors. E 1 / 1 (100%) Time: 845 ms, Memory: 8.00MB There was 1 error: 1) TestsFeatureMentionUsersTest::test_it_can_fetch_all_mentioned_users_starting_with_the_given_characters BadMethodCallException: Method [index] does not exist on [AppHttpControllersApiUsersController].
It looks like we’re missing in index() method. Easy fix!
<?php
namespace AppHttpControllersApi;
use AppHttpControllersController;
use AppUser;
class UsersController extends Controller
{
public function index()
{
}
}
The index method does not yet do anything, but it should clear the prior error in the test and get us one step closer to green. We can run the test again to see what we need to do.
vagrant@homestead:~/Code/forumio$ phpunit --filter test_it_can_fetch_all_mentioned_users_starting_with_the_given_characters PHPUnit 6.5.5 by Sebastian Bergmann and contributors. F 1 / 1 (100%) Time: 810 ms, Memory: 10.00MB There was 1 failure: 1) TestsFeatureMentionUsersTest::test_it_can_fetch_all_mentioned_users_starting_with_the_given_characters Invalid JSON was returned from the route.
Ok, invalid json is returned. We can fix that. First off, what do we need this index() method to do? Well, it is going to make a database query. Specifically, it is going to query the User model, so we need to make use of that. Now, we will use a “where” clause using the passed in data from the front end. Once that is complete, we return the results as json. Here is what that should look like now.
<?php
namespace AppHttpControllersApi;
use AppHttpControllersController;
use AppUser;
class UsersController extends Controller
{
public function index()
{
$search = request('name');
return User::where('name', 'LIKE', "%$search%")
->take(5)
->pluck('name');
}
}
With that, we see my favorite color. GREEN!
That means this should now work in the browser. If we try it, it kind of works. The potential matches are showing up, but not how we might expect!
Adding At.js CSS
So the reason why things look funny so far, is that we have no styling to properly format the auto suggest results. We can fix that however by copying the css which comes with at.js to the public directory of our application. First we create the new directory within public/css, then we copy the contents over.
vagrant@homestead:~/Code/forumio$ mkdir public/css/vendor vagrant@homestead:~/Code/forumio$ cp node_modules/at.js/dist/css/jquery.atwho.css ./public/css/vendor/
Selectively Referencing A Stylesheet
We now need to reference that stylesheet in order for the styling to take effect in the form. We don’t need this stylesheet anywhere else in the application however, so we should make use of it only when needed. This is actually fairly easy to do. In threads/show.blade.php we will add this markup.
@extends('layouts.app')
@section('head')
<link rel="stylesheet" href="/css/vendor/jquery.atwho.css">
@endsection
@section('content')
Now in the main app.blade.php layout file, we can add the highlighted line.
<!DOCTYPE html>
<html lang="{{ app()->getLocale() }}">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- CSRF Token -->
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ config('app.name', 'Laravel') }}</title>
<!-- Styles -->
<link href="{{ asset('css/app.css') }}" rel="stylesheet">
<script>
window.App = {!! json_encode([
'user' => Auth::user(),
'signedIn' => Auth::check()
]) !!};
</script>
<!-- My Basic Styles -->
<style>
body {
padding-bottom: 100px;
}
.level {
display: flex;
align-items: center;
}
.flex {
flex: 1;
}
.mr-1 {
margin-right: 1em;
}
[v-cloak] {
display: none;
}
</style>
@yield('head')
</head>
<body>
<div id="app">
@include('layouts.nav')
@yield('content')
<flash class="alert-flash" message="{{ session('flash') }}"></flash>
</div>
<!-- Scripts -->
<script src="{{ asset('js/app.js') }}"></script>
</body>
</html>
This allows us to add the needed stylesheet on demand so to speak. And will you look at that?! Cool!
Linkify User Names
The last thing we can do in this tutorial is to make sure that the user names are linkified, or wrapped in an anchor tag when mentioning a user. In the Reply.php model, we can add a public function to set the body attribute like so. This just makes use of a handy regular expression to create a hyperlink for user profiles.
public function setBodyAttribute($body)
{
$this->attributes['body'] = preg_replace(
'/@([w-]+)/',
'<a href="/profiles/$1">$0</a>',
$body
);
}
We’ll also need to make a small tweak in the Reply.vue component. Note that we now use v-html instead of v-text.
<div class="panel-body">
<div v-if="editing">
<div class="form-group">
<textarea class="form-control" v-model="body"></textarea>
</div>
<button class="btn btn-xs btn-primary" @click="update">Update</button>
<button class="btn btn-xs btn-link" @click="editing = false">Cancel</button>
</div>
<div v-else v-html="body"></div>
</div>
Now our usernames get linkified and we can click through to see their profiles.
jQuery Autocomplete As You Type Summary
We were able to get the auto suggest feature working in our application using the very useful at.js jQuery plugin. Once we added a small amount of markup to the front end, we had to set up an api on the backend to return the potential matches. In addition, we had to copy over some custom css in order to render the auto complete of user names so that they look slick in the browser.