We’re on a roll working with VueJS, components, and ajaxified buttons in our view files. It was helpful to start off with no JavaScript at all, then progressively add in JavaScript to make the user experience better. At this point our edit and delete buttons for a reply are fully dynamic. In this tutorial, we will create a child component in VueJS so that we can give the ajax treatment to the Favorite button.
Define A New Component
We want to convert the existing favorite button on a reply into a dedicated Vue component. So first, we’ll imagine how we want to reference the favorite component in the view file. How about <favorite></favorite>? This makes sense, and we’ll also want to pass in the $reply data from Laravel as a prop. So ultimately we will start with this markup in the blade file.
<favorite :reply="{{ $reply }}"></favorite>
We’ll now create a new file named Favorite.vue within the components directory and add the <template> and <script> sections to begin.
<template>
</template>
<script>
export default {
}
</script>
Our goal in the reply.blade.php file is to be able to remove all of the commented out markup, and simply replace it with the new highlighted component.
<div>
<favorite :reply="{{ $reply }}"></favorite>
{{--<form method="POST" action="/replies/{{$reply->id}}/favorites">--}}
{{--{{csrf_field()}}--}}
{{--<button type="submit" class="btn btn-primary {{ $reply->isFavorited() ? 'disabled' : '' }}">--}}
{{--{{ $reply->favorites_count }} {{ str_plural('Favorite', $reply->favorites_count) }}--}}
{{--</button>--}}
{{--</form>--}}
</div>
Building The <template>
In the <template> area of the component is where the markup is placed. We need a button to represent the element a user can click to favorite or unfavorite a reply. We can add some markup like so then to the <template> area of the Vue component. We’ll start with this.
<template>
<button type="submit" class="btn btn-default">
<span class="glyphicon glyphicon-heart"></span>
<span v-text="count"></span>
</button>
</template>
<script>
export default {
data() {
return {
count: 1
}
}
}
</script>
Notice we also set up the data() method which returns a favorite count
defaulted to 1. Now we can run yarn run watch-poll
to turn on automatic file watching and re compilation so that as we make changes we can visit the browser to see the result.
Defining A Child Component
The Favorite.vue component is to be a child of the Reply.vue component. This can be defined in Reply.vue as we see here. Two things need to happen. We need to import the component, and we also need to define it in the components property.
<script>
import Favorite from './Favorite.vue';
export default {
props: ['attributes'],
components: {Favorite},
data() {
return {
editing: false,
body: this.attributes.body
};
},
methods: {
update() {
axios.patch('/replies/' + this.attributes.id, {
body: this.body
});
this.editing = false;
flash('Updated!');
},
destroy() {
axios.delete('/replies/' + this.attributes.id);
$(this.$el).fadeOut(1000, () => {
flash('Your reply is now deleted!');
});
}
}
};
</script>
After a quick second, webpack should have done it’s recompile and we can check in the browser to see how things are going. It looks like the child component is now rendering quite nicely. Excellent!
Define an event listener on the child
Now we want to define an event listener on the Favorite child component. When someone clicks this button, the goal is to trigger a an es6 method that toggles which we will define shortly on the Vue instance. That click handler is set up like so.
<template>
<button type="submit" class="btn btn-default" @click="toggle">
<span class="glyphicon glyphicon-heart"></span>
<span v-text="count"></span>
</button>
</template>
That means we need to define the toggle() method now within the methods object of the Vue instance. In addition, the toggle() method is going to need access to information about the $reply in question. Well, we had passed this in as an attribute when we defined <favorite :reply=”{{ $reply }}”></favorite>. This means we can fetch that data so to speak in Vue via the props property. Highlighted here are the props and new method. Parent components communicate to child components via props.
<template>
<button type="submit" class="btn btn-default" @click="toggle">
<span class="glyphicon glyphicon-heart"></span>
<span v-text="count"></span>
</button>
</template>
<script>
export default {
props: ['reply'],
data() {
return {
count: 1
}
},
methods: {
toggle() {
if (this.isFavorited) {
axios.delete('/replies/' + this.reply.id + '/favorites');
} else {
axios.post('/replies/' + this.reply.id + '/favorites');
}
}
}
}
</script>
This represents your fairly typical favorite type scenario. If the reply is already favorited, then unfavorite it, otherwise create a new favorite for the reply. In constructing the endpoint to send the axios request to, we are making use of the data we had accepted from the prop. Hopefully that makes sense.
Refactoring The Reply Model
We might need to update the backend in Laravel just a bit to get this to work correctly. It turns out we’ll need to use the $appends property on the Reply model. This property exists for when you are casting to an array or to json, allowing you to specify any custom attributes to append to that casting. We want the favorites count. So we can add this line to the Reply model.
<?php
namespace App;
use IlluminateDatabaseEloquentModel;
class Reply extends Model
{
use Favoriteable, RecordsActivity;
protected $guarded = [];
protected $with = ['owner', 'favorites'];
protected $appends = ['favoritesCount'];
public function owner()
{
return $this->belongsTo(User::class, 'user_id');
}
public function thread()
{
return $this->belongsTo(Thread::class);
}
public function path()
{
return $this->thread->path() . '#reply-' . $this->id;
}
}
How did we determine the string to use here? Well, the Reply model actually uses the Favoriteable trait. In that trait is a custom getter.
public function getFavoritesCountAttribute()
{
return $this->favorites->count();
}
So we follow the convention and use ‘favoritesCount’ as the string to pass to $appends. To show that this now gives us access to the favorites count in the Vue side, we can load up a page with a reply that we know has a favorite. Now, inspect with Vue developer tools, and we see that very property that we are looking for.
Since we now have access to that count, we should remove the hard coding of a count in the Vue data object, and use this.reply.favoritesCount to set that value like we see.
data() {
return {
count: this.reply.favoritesCount
}
},
Computed Attributes For :class
On clicking the button, the user should see state change on the button itself. It should look different based on whether that user has favorited the reply or not. How might we tackle that? What we are going to do is to remove the hard coding of a class on the button element inside the template. Instead, we will bind that element to a custom computed attribute named classes. Now classes() will actually be a method in the computed property of Vue. Based on a small amount of logic, the method will apply one style class, or a different style class. Here is the first stab at that.
<template>
<button type="submit" :class="classes" @click="toggle">
<span class="glyphicon glyphicon-heart"></span>
<span v-text="count"></span>
</button>
</template>
<script>
export default {
props: ['reply'],
data() {
return {
count: this.reply.favoritesCount,
active: false,
}
},
computed: {
classes() {
return ['btn', this.active ? 'btn-primary' : 'btn-default'];
}
},
methods: {
toggle() {
if (this.active) {
axios.delete('/replies/' + this.reply.id + '/favorites');
} else {
axios.post('/replies/' + this.reply.id + '/favorites');
this.active = true;
}
}
}
}
</script>
Loading up a reply in the browser first displays the default class. Why? Because active is hard coded to false. Now, if we click the button, it does change state. How? Because in the toggle() method, if the else branch is taken we can see that this.active gets updated to true.
Updating the count in real time
So we got the state change working pretty good, but we saw that the number on the favorite did not go up in real time. This is very easy to fix. In the toggle method when setting this.active to true, right after this just increment the count with this.count++;. Let’s see if that works.
A Delete Favorite Endpoint Is Needed
In order to finish building out the Favorite child component in Vue, we need to add an endpoint on the Laravel side to allow for removing a favorite from a reply. We already have a FavoritesTest class, so we can just add a new test method to begin.
public function test_an_authenticated_user_can_unfavorite_any_reply()
{
$this->withoutExceptionHandling();
$this->signIn();
$reply = create('AppReply');
$this->post('replies/' . $reply->id . '/favorites');
$this->assertCount(1, $reply->favorites);
$this->delete('replies/' . $reply->id . '/favorites');
$this->assertCount(0, $reply->fresh()->favorites);
}
Really quickly, this test just says that if we’re signed in and we submit a favorite, then there should be 1 favorite in the database. If we then delete that favorite, there should be 0 favorites in the database. We know the test will fail for the usual reasons, so let’s just start building out the code we need to make it pass.
Of course we’ll need a route for this endpoint, and a new destroy() method on the FavoritesController.
web.php
<?php
Route::get('/', function () {
return view('welcome');
});
Auth::routes();
Route::get('/home', 'HomeController@index')->name('home');
Route::get('/threads', 'ThreadsController@index');
Route::get('/threads/create', 'ThreadsController@create');
Route::get('/threads/{channel}/{thread}', 'ThreadsController@show');
Route::delete('/threads/{channel}/{thread}', 'ThreadsController@destroy');
Route::post('/threads', 'ThreadsController@store');
Route::get('/threads/{channel}', 'ThreadsController@index');
Route::post('/threads/{channel}/{thread}/replies', 'RepliesController@store');
Route::patch('/replies/{reply}', 'RepliesController@update');
Route::delete('/replies/{reply}', 'RepliesController@destroy');
Route::post('/replies/{reply}/favorites', 'FavoritesController@store');
Route::delete('/replies/{reply}/favorites', 'FavoritesController@destroy');
Route::get('/profiles/{user}', 'ProfilesController@show')->name('profile');
FavoritesController.php
<?php
namespace AppHttpControllers;
use AppFavorite;
use AppReply;
use IlluminateHttpRequest;
class FavoritesController extends Controller
{
public function __construct()
{
$this->middleware('auth');
}
public function store(Reply $reply)
{
$reply->favorite();
return back();
}
public function destroy(Reply $reply)
{
$reply->unfavorite();
}
}
In the Favoriteable.php trait, we’ll now add the unfavorite() method.
<?php
namespace App;
trait Favoriteable
{
public function favorites()
{
return $this->morphMany(Favorite::class, 'favorited');
}
public function favorite()
{
$attributes = ['user_id' => auth()->id()];
if (!$this->favorites()->where($attributes)->exists()) {
return $this->favorites()->create($attributes);
}
}
public function unfavorite()
{
$attributes = ['user_id' => auth()->id()];
$this->favorites()->where($attributes)->delete();
}
public function isFavorited()
{
return !!$this->favorites->where('user_id', auth()->id())->count();
}
public function getFavoritesCountAttribute()
{
return $this->favorites->count();
}
}
With a quick run of the new test, we see everything works.
vagrant@homestead:~/Code/forumio$ phpunit --filter test_an_authenticated_user_can_unfavorite_any_reply PHPUnit 6.5.5 by Sebastian Bergmann and contributors. . 1 / 1 (100%) Time: 1.3 seconds, Memory: 10.00MB OK (1 test, 2 assertions)
One more thing we should do on the Laravel side is to set up a custom getter or custom attribute on the Favoriteable trait. We are doing this so that we can pass it into the Vue side. Add this method like so to Favoriteable.php
public function getIsFavoritedAttribute()
{
return $this->isFavorited();
}
Now in the Reply.php model, add this to $appends like we discussed earlier.
protected $appends = ['favoritesCount', 'isFavorited'];
Finally, if we inspect the Vue components in Vue developer tools, that attribute is now available and we can make use of it.
Back To Vue and Favorite.vue component
Now we make everything dynamic in the Favorite component like so.
<script>
import Favorite from './Favorite.vue';
export default {
props: ['attributes'],
components: {Favorite},
data() {
return {
editing: false,
body: this.attributes.body
};
},
methods: {
update() {
axios.patch('/replies/' + this.attributes.id, {
body: this.body
});
this.editing = false;
flash('Updated!');
},
destroy() {
axios.delete('/replies/' + this.attributes.id);
$(this.$el).fadeOut(1000, () => {
flash('Your reply is now deleted!');
});
}
}
};
</script>
It looks like it is working as we expect. Both the class and the count update in real time and accurately reflect the immediate status of the favorite. Pretty cool!
How To Create A Child Component In VueJS Summary
With that, another fun tutorial is in the books. In this episode, we saw how to create a new Favorite component that is a child of the existing Reply component using VueJS. There were a few important points to remember such as making sure to import the child into the parent as well as defining the child inside the components property of the parent. We also had some good practice with passing data around using various bindings, props, and custom attributes along with the $appends property in Laravel.