Vue.js Reusable Components

Bootstrap confirm-delete dialog modal in a Laravel based web app

David Abiola
11 min readFeb 21, 2020
Photo by Volodymyr Hryshchenko on Unsplash

Disclaimer:

This content is developed to give junior-intermediate Laravel/Vue.js developers (or enthusiasts) a general sense of why and how to implement reusable vue.js components in a Laravel-based-backend application. My featured implementation isn’t necessarily ‘perfect/superior/acclaimed’. My intention is to help you find a path to your own ‘perfect’ implementation — one that’ll hopefully suit your use case better.

Before we begin, I’d like to assume a few things — not covered herein:

  • You already have some experience working with Laravel, Vue.js, NPM, and other such related development tools.
  • You have a working installation of Laravel based application
  • Your database is setup
  • already installed NPM modules
  • You’ve included jQuery JavaScript Library and Bootstrap CSS framework
  • and you have a terminal in place to run some commands

Some technical details about my local setup — just for reference

Versions:

  • Nginx ~ 1.16
  • MariaDB ~ 10.2.3
  • PHP ~ 7.4
  • Laravel ~ 6.13

The Big Idea?

Reusable Vue components can offer a lot more usage flexibility within our apps. It means you wouldn’t need to whip up an entirely new identical component just to vary a few params on a case-to-case basis. You’ll see just what I mean in a bit.

Part 1: Frontend stuff

Step 1: create a user_profiles.js file with a new instance of Vue in our resources directory.

We’ll use el API to specify an existing DOM element to mount on. In this case: user-profiles

import Vue from 'vue';
import UserProfiles from '../components/UserProfiles';

new Vue({
el: 'user-profiles',
components:{
UserProfiles
}
});

Step 3: compile the script (associated modules) down with Laravel Mix, into the public directory.

  • In webpack.mix.js, on the mix object, we’ll specify what path to compile all our associated JS modules into, using the js method’s second argument.

It’ll be a path within our app’s public directory—Like so:

.js('resources/js/plugins/user_profiles.js', 'public/js/plugins')

With the necessary modifications, our webpack.mix.js should end up looking close to this:

Don’t forget to comment out whatever is already compiled and (or) isn’t expected to be modified at this time

mix.js('resources/js/app.js', 'public/js')
.js('resources/js/plugins/user_profiles.js', 'public/js/plugins')
// .sass('resources/sass/app.scss', 'public/css');

At this point, we may start to watch for changes to any parts of our module by running:

// auto recompile when any changes are madenpm run watch

Of course, we’ll get some errors about the following import statement, since we haven’t yet created that.

import UserProfiles from ‘../components/UserProfiles’;

Let’s go whip that up now —

Step 4: Create the component (UserProfiles.vue) defined above.

Inside the UserProfiles component

Our component’s template contains a level-2 header with a simple text “Profiles” and a badge indicating the number of objects present within our array of data — or simply ‘the number of users/profiles’, for clarity.

<h2>Profiles <span class="badge badge-pill badge-dark">{{ users.length }}</span></h2>

We also have a div of a bootstrap row where we make sure our data isn’t empty and then loop over the collection and rendering each profile in its own bootstrap card.

<div class="row" v-if="users.length > 0">...</div>

Each profile will be rendered in a card and will have its own confirm-delete-button:

<div class="card">
<div class="card-body">
<h5 class="card-title">{{ user.name }}</h5>
<p class="card-text">{{ user.email }}</p>

<confirm-delete-button></confirm-delete-button>
</div>
</div>

If our data turns out to be empty, we’ll give our app users some feedback, which you’ll agree is important for better User Experience. We’ll keep it simple; like so —

<div class="row" v-else>
<div class="card">
<div class="card-body">
There are no profiles yet.
</div>
</div>
</div>

Finally, our template will also feature a bootstrap dialog modal — one of the components at the core of this feature we’re developing. The UI will be embedded within the template of this component:

<confirm-delete-modal
listening-for="profile-delete-called"
deletion-url-prefix="/profiles"
delete-completion-event="profile-delete-successful">
<template v-slot:warning-message="{ model }">
Are you sure you want to delete <strong>{{ model.name }}</strong>'s profile?
</template>
<template v-slot:proceed-with-delete-button-text>
<span>Yes, delete this profile</span>
</template>
</confirm-delete-modal>

Putting all these pieces together, our template will look like the following:

<template>
<div>
<h2>Profiles <span class="badge badge-pill badge-dark">{{ users.length }}</span></h2>
<div class="row" v-if="users.length > 0">
<div class="col-sm-4 pb-2" v-for="user in users" :key="user.id">
<div class="card">
<div class="card-body">
<h5 class="card-title">{{ user.name }}</h5>
<p class="card-text">{{ user.email }}</p>

<confirm-delete-button
event-call="profile-delete-called"
:payload="{data: user}">
<template v-slot:button>
<span class="btn btn-danger">Delete profile</span>
</template>
</confirm-delete-button>
</div>
</div>
</div>

<confirm-delete-modal
listening-for="profile-delete-called"
deletion-url-prefix="/profiles"
delete-completion-event="profile-delete-successful">
<template v-slot:warning-message="{ model }">
Are you sure you want to delete <strong>{{ model.name }}</strong>'s profile?
</template>
<template v-slot:proceed-with-delete-button-text>
<span>Yes, delete this profile</span>
</template>
</confirm-delete-modal>
</div>

<div class="row" v-else>
<div class="card">
<div class="card-body">
There are no profiles yet.
</div>
</div>
</div>
</div>
</template>

<script>
import ConfirmDeleteButton from '../components/ConfirmDeleteButton';
import ConfirmDeleteModal from '../components/ConfirmDeleteModal';

export default {
props: ['profiles'],
data() {
return {
users: this.profiles
}
},
components: {
ConfirmDeleteButton,
ConfirmDeleteModal
},
mounted() {
this.$root.$on('profile-delete-successful', ({model}) => {
this.removeDeletedProfile(model);
})
},
methods: {
removeDeletedProfile(model) {
this.users.splice(this.users.indexOf(model), 1);
}
}
};
</script>

We’ll need to go build up these components we’re importing:

import ConfirmDeleteButton from '../components/ConfirmDeleteButton';
import ConfirmDeleteModal from '../components/ConfirmDeleteModal';

Before we tackle that, let’s see what’s going on inside our exported module —

So, the collection of users/profiles gets sent through to our component via the props, after it’s been loaded from the backend. Then this is set to be returned as a value for our users data property:

...props: ['profiles'],
data() {
return {
users: this.profiles
}
},
...

Once our component is mounted, we’ll start listening for a specific deletion event; ‘profile-delete-successful’, so that we can react, and make necessary updates on-the-fly wherever necessary. In this case, the event will fire whenever a successful deletion has been made in the backend of our application, and then we’ll delete the object from the list rendered in the frontend using the splice JS array method:

...mounted() {
this.$root.$on('profile-delete-successful', ({model}) => {
this.removeDeletedProfile(model);
})
},
methods: {
removeDeletedProfile(model) {
this.users.splice(this.users.indexOf(model), 1);
}
}
...

Quick todo

Let’s quickly create a blade template to house our Vue component:

<user-profiles
:profiles="{{ $profiles }}">
</user-profiles>

Step 5: We need a blade template to render the component above

  • Let’s create profiles.blade.php and extend our parent template app
  • include the compiled script at the bottom of our page
@extends('layouts.app')

@section(
'content')
<div class="container">
<div class="row">
<div class="col-md-12">
<user-profiles
:profiles="{{ $profiles }}">
</user-profiles>
</div>
</div>
</div>
@endsection

@section(
'scripts')
<script src="/js/plugins/user_profiles.js "></script>
@endsection

Now let’s proceed with building the rest of our components.

Step 6: build up the components of the UserProfiles component —

1. ConfirmDeleteButton
2. ConfirmDeleteModal
  • ConfirmDeleteButton.vue
<confirm-delete-button
event-call="profile-delete-called"
:payload="{data: user}">
<template v-slot:button>
<span class="btn btn-danger">Delete profile</span>
</template>
</confirm-delete-button>

This component’s module expects the two props supplied above:

// we want to trigger the event matching the following name at the // click of the button1. event-call="profile-delete-called"// we want to receive the object being interacted on as value
// for the key data once this button is clicked
2. :payload="{data: user}"

In summary, when the button is clicked, we’ll:

  • fire off a “profile-delete-called” event, and then
  • send off the corresponding user model as the value of the key data: {data: user}, to any listener(s) waiting on that specific event
<template>
<a @click="deleteDialog">
<slot name="button"></slot>
</a>
</template>

<script>
export default {
props: ['eventCall', 'payload'],
methods: {
deleteDialog() {
this.$root.$emit(this.eventCall, this.payload);
}
}
};
</script>

The best bit about the way we’ve set this up is how we can :

  • specify on a button-to-button basis, what event we want to emit when it’s clicked. That is, you can have multiple buttons each emitting a different event when clicked.
  • and customize our button’s UI (including text)
...<template v-slot:button>
<span class="btn btn-danger">Delete profile</span>
</template>
...
  • ConfirmDeleteModal.vue
<confirm-delete-modal
listening-for="profile-delete-called"
deletion-url-prefix="/profiles"
delete-completion-event="profile-delete-successful">
<template v-slot:warning-message="{ model }">
Are you sure you want to delete <strong>{{ model.name }}</strong>'s profile?
</template>
<template v-slot:proceed-with-delete-button-text>
<span>Yes, delete this profile</span>
</template>
</confirm-delete-modal>

Technical notes:

In this component, let’s observe the reusability of this component from the following point-of-views

  1. you can specify what event to listen for on a specific component
listening-for="profile-delete-called"

2. we can specify a prefix for our delete route to take full advantage of the reusability we’ve baked into the component:

deletion-url-prefix="/profiles"// see what URL axios calls: //axios.delete(`${this.deletionUrlPrefix}/${this.model.id}/delete`)
// in this case, ${this.deletionUrlPrefix} is "/profiles"
// to give axios.delete(`profiles/${this.model.id}/delete`)
// This means we can setup a convention for naming our routes in the backend, and then utilize this advantage in the frontend, ie., we can have another route URL like:// '/comments/${this.model.id}/delete'// and simply customize the deletion-url-prefix prop accordingly, ie. deletion-url-prefix="/comments"

3. We can also customize what event gets fired off after our deletion API returns ‘ok’ from our backend.

delete-completion-event="profile-delete-successful"

4. We can also customize the message to display for warning whenever our modal pops open, via the warning-message slot. We even went a step further to passing the model through to the slot template so we can print the value of the key ‘name’ on the specified model. This adds an extra layer of personalization and security to our deletion feature, especially considering it’s such a sensitive process in our app.

<template v-slot:warning-message="{ model }">
Are you sure you want to delete
<strong>{{ model.name }}</strong>'s profile?
</template>

5. we’ve also ensured customizing our actual deletion button isn’t a hassle, with the proceed-with-delete-button-text slot:

<template v-slot:proceed-with-delete-button-text>
<span>Yes, delete this profile</span>
</template>

Now, let’s take a look inside our component’s — template & module.

<template>
<div class="modal fade" tabindex="-1" role="dialog" aria-hidden="true" ref="deleteDialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 v-if="model" class="modal-title">
<slot name="warning-message" :model="model">Warning</slot>
</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
<button v-if="model" :disabled="disabled" class="btn btn-danger" @click="executeDeletion">
<slot name="proceed-with-delete-button-text">Are you sure?</slot>
</button>
</div>
</div>
</div>
</div>
</template>

<script>
import notify from './mixins/noty';

export default {
props: ['listeningFor', 'deletionUrlPrefix', 'deleteCompletionEvent'],
data() {
return {
model: null,
disabled: true
}
},
mounted() {
this.$root.$on(this.listeningFor, ({data}) => {
this.updateModel(data)
.openConfirmDialogModal();

this.disabled = false;
})
},
mixins: [notify],
methods: {
updateModel(data) {
this.model = data;

return this;
},
openConfirmDialogModal() {
$(this.$refs.deleteDialog).modal();
},
executeDeletion() {
if (this.model) {
this.disabled = true;
axios.delete(`${this.deletionUrlPrefix}/${this.model.id}/delete`)
.then(({ data }) => {
this.closeConfirmDialogModal()
.broadcastModelDeletion()
.clearModelProp()
.notify('success', data.message)
;
})
.catch(err => {
console.error(err);

this.disabled = false;
});
} else this.notify('warning', 'Please select a model first.');
},
broadcastModelDeletion() {
this.$root.$emit(this.deleteCompletionEvent, {model: this.model});

return this;
},
closeConfirmDialogModal() {
$(this.$refs.deleteDialog).modal('hide');

return this;
},
clearModelProp() {
this.model = null;

return this;
}
}
};
</script>

In here, every time a button in our previously defined component is clicked,

Quick Reminder

<template>
<a @click="deleteDialog">
...
</a>
</template>

<script>
export default {
props: ['eventCall', 'payload'],
methods: {
deleteDialog() {
this.$root.$emit(this.eventCall, this.payload);
}
}
};
</script>

a model is passed to the listener through the $emit API, and that model is then used to update the value of model data property of this particular component listening for that event emitted, as follows:

...mounted() {
this.$root.$on(this.listeningFor, ({data}) => {
this.updateModel(data)
.openConfirmDialogModal();

this.disabled = false;
})
},
...
// once the model data property is no longer null, we'll trigger our modal open and ready for deletion confirmation, then enable the deletion button

We can then give the go-ahead to delete the selected model in the backend of our app:

...executeDeletion() {
if (this.model) {
this.disabled = true;
axios.delete(`${this.deletionUrlPrefix}/${this.model.id}/delete`)
.then(({ data }) => {
this.closeConfirmDialogModal()
.broadcastModelDeletion()
.clearModelProp()
.notify('success', data.message)
;
})
.catch(err => {
console.error(err);

this.disabled = false;
});
} else this.notify('warning', 'Please select a model first.');
},
...

Once the request to our server returns a success response, as seen above —

  • we’ll close the modal
  • fire off a custom event to sort of broadcast this information so that the necessary changes can be handled accordingly by the component(s) affected by these change(s)
  • clear the model selection
  • and notify the user with a success message

Also worth mentioning that, inside the method used to initialize our modal, we targeted the dom directly with Vue’s $refs API, instead of having to maybe deal with custom query selectors like id.

// targeting the dom @ 
// <div class="modal fade" tabindex="-1" role="dialog" aria-hidden="true" ref="deleteDialog">...</div>
...openConfirmDialogModal() {
$(this.$refs.deleteDialog).modal();
},
...

Another quick note

Notice how we didn’t need to import axios in the above component? Well, that’s because we already did that globally in our precompiled bootstrap.js file, like so:

/**
* We'll load the axios HTTP library which allows us to easily issue requests
* to our Laravel backend. This library automatically handles sending the
* CSRF token as a header based on the value of the "XSRF" token cookie.
*/

window.axios = require('axios');

Also, notice how I have my notification module plugged in via mixins? That’s set up to avoid repetition with codes like this, that are likely to be used within various parts of my frontend components.

Don’t forget to install Noty before importing it into your JS module

via NPM:

npm install noty

and add styling files to resources/assets/sass/app.scss

// Fonts
@import url('https://fonts.googleapis.com/css?family=Nunito');

// Variables
@import 'variables';

// Bootstrap
@import '~bootstrap/scss/bootstrap';

// Noty
@import "~noty/src/noty.scss";
@import "~noty/src/themes/mint.scss";

There’s just a single method in here, for now, so I just did a default export:

import Noty from 'noty';

export default {
methods: {
notify(type, text){
new Noty({
text,
type,
layout: 'top',
theme: 'mint',
timeout: 3000,
}).show();
}
}
};

and then imported with:

import notify from './mixins/noty';

If you’re not already familiar with Vue mixins, you may have to research on that bit for clarity.

Part 2: Backend bits

Step 1: setup routes to handle intended requests

  • GET request to load up every user’s profiles
  • DELETE request to implement profile deletion

Let’s set these up in our web.php file —

// confirm delete feature
Route::get('/users/profiles', 'ProfileController@index');

Route::delete('/profiles/{user}/delete', 'ProfileController@destroy');

Observe the above:

  • When a GET request matching ‘/users/profiles’ URL is sent to our server, it should be resolved through the index() method on the ProfileController’ (within the same namespace).
  • Any DELETE requests sent to our server matching ‘/profiles/{user}/delete’, where {user} is a required parameter (in our case; a valid User id), should be resolved through the destroy() method on the ProfileController’ (within the same namespace).

Step 2: set up the ProfileController referenced above

php artisan make:controller ProfileController

And then we’ll set up the methods —

I optionally extended the controller methods to accommodate for; either a traditional Laravel type returned view (with some data), or a JSON type returned response.

<?php

namespace
App\Http\Controllers;

use App\User;

class ProfileController extends Controller
{
public function index()
{
if (request()->expectsJson()) {
return response([
'profiles' => User::all()
], 200);
}

return view('profiles', ['profiles' => User::all()]);
}

public function destroy(User $user)
{
$user->delete();

if(request()->expectsJson()) {
return response([
'message' => $user->name . "'s profile has been deleted."
], 200);
}

return view('profiles', [
'message' => $user->name . "'s profile has been deleted."
]);
}
}
  • For the purpose of this example, In the index method, we’ll just fetch all User models and return the collection into a variable — profiles, and pass them on to the profiles’ blade template.

Note: my users table is already pre-populated with some seeded User factory data

  • The method destroy(User $user) method will retrieve the instance of the User model matching the primary key — $user (from the users table), which is the id passed in as an argument in our previously defined DELETE route.
Route::delete('/profiles/{user}/delete', 'ProfileController@destroy');
  • Since this request will be processed via Axios, we’ll return a response with a message, and a 200 status code to indicate the request was handled ‘ok’.

And with that, we’re good to go!

Associated Github commit:

I’ll push the codes in this lesson to a branch on my public Github repo where you can go over my them for reference:

https://github.com/davealex/experiments/tree/ft-confirm-delete-component

I’m David — A Nigerian software engineer, coffee drinker, and innovator. Thanks for tuning in!

--

--

David Abiola

Human & software engineer. Interested in ()=> {[Arts, Education, Music, Science, Tech, AI, co-Humans]};