Previously I’ve used Datatables.net to build my tables on the Laravel blade templates for user interface. Now I’ve been migrating over to Vue.js I thought I’d look at another option – more Vue.js centric.
This is where vuetable-2 (not to be confused with other vue components of a similar name!) came in. I tried a lot of other vue tables and most of them had a dependency for jQuery. I really wanted one that didn’t use jQuery – I know Bootstrap has it as a dependency, but if I ever decide to remodel the UX using something like Material Design, I’d have no need to include jQuery.
As a component I needed to do a lot of work to configure it to work with my setup. But only really because I wanted it to work with Bootstrap 4 and fontawesome. So I had to build vue component templates that presented me with the correct structure and classes for the pagination and search.
The examples in the Gists relate to my admin interface to manage a Laravel user model of users and roles.
(see gists below)
JWTAuth
Then I ran into a problem with fetching data. Vuetable default Axios call to retrieve data to populate the table meant that no Authorization header was being sent. The default property for the vuetable api-url
uses Axios, but obviously a different instance to the Vue plugin, as it doesn’t include the @websnova/vue-auth
component.
The resolution was simple. Add a property to use your own http-fetch function, but just use the defaults, no need to add anything as effectively using this.http$
uses the Vue plugin Axios instance that includes the @wesanova
parts.
api-url="myApi" :http-fetch="getData"
Then add a matching method, but do nothing special:
getData(apiUrl, httpOptions) { return this.$http.get(apiUrl, httpOptions) },
Gists
<template> | |
<div id="users"> | |
<div class="row"> | |
<vuetable-filter-bar | |
class="col-sm-6" | |
/> | |
<!— Api call should return standard Laravel paginate() json —> | |
<vuetable-pagination | |
ref="pagination" | |
class="col-sm-6" | |
@vuetable-pagination:change-page="onChangePage" | |
/> | |
</div> | |
<!— Generate the table based on the default api call —> | |
<vuetable | |
ref="vuetable" | |
:fields="fields" | |
:css="css" | |
:sort-order="sortOrder" | |
:append-params="moreParams" | |
:http-fetch="getUsers" | |
class="table-striped table-hover" | |
api-url="/admin/users" | |
pagination-path="" | |
@vuetable:pagination-data="onPaginationData" | |
> | |
<template | |
slot="roles" | |
slot-scope="props" | |
> | |
<!— Iterate the roles and build the buttons —> | |
<button | |
v-for="role in props.rowData.roles" | |
:key="role.name" | |
class="btn btn-sm btn-primary mr-1 mb-1" | |
> | |
{{ role.name }}<!— <i class="fas fa-times"/>—> | |
</button> | |
<!— add role button fires the modal —> | |
<button | |
class="btn btn-sm btn-default text-dark mr-1 mb-1" | |
@click="addRoles(props.rowData)" | |
> | |
<i class="fas fa-pencil-alt"/> | |
</button> | |
</template> | |
</vuetable> | |
<div class="row"> | |
<!— Api call should return standard Laravel paginate() json —> | |
<vuetable-pagination-dropdown | |
ref="paginationDropdown" | |
class="offset-sm-6 col-sm-6" | |
@vuetable-pagination:change-page="onChangePage" | |
/> | |
</div> | |
<!— Modal for the updating the current users roles —> | |
<div | |
id="rolesModal" | |
class="modal fade" | |
tabindex="-1" | |
role="dialog" | |
> | |
<div | |
class="modal-dialog modal-dialog-centered" | |
role="document" | |
> | |
<div class="modal-content"> | |
<form | |
name="rolesForm" | |
action="" | |
method="put" | |
@submit.prevent="saveRoles" | |
> | |
<input | |
type="hidden" | |
name="_method" | |
value="put" | |
> | |
<input | |
:value="user.username" | |
name="user" | |
type="hidden" | |
> | |
<div class="modal-header"> | |
<h5 class="modal-title">Roles for User "{{ user.name }}"</h5> | |
<button | |
type="button" | |
class="close" | |
data-dismiss="modal" | |
aria-label="Close"> | |
<span aria-hidden="true">×</span> | |
</button> | |
</div> | |
<div class="modal-body"> | |
<div class="row"> | |
<div | |
v-for="role in roles" | |
:key="role.name" | |
class="col-sm-4" | |
> | |
<!— | |
Checkboxes are bound to a non-indexed array where the id | |
is the value of the array item | |
—> | |
<div class="form-group"> | |
<label :for="role.name"> | |
<input | |
:id="role.name" | |
:value="role.name" | |
v-model="newRoles" | |
type="checkbox" | |
> | |
{{ role.name }} | |
</label> | |
</div> | |
</div> | |
</div> | |
</div> | |
<div class="modal-footer"> | |
<button | |
type="button" | |
class="btn btn-default" | |
data-dismiss="modal" | |
> | |
Close <i class="fas fa-times"/> | |
</button> | |
<button | |
type="button" | |
class="btn btn-danger" | |
@click="newRoles = []" | |
> | |
None <i class="fas fa-trash-alt"/> | |
</button> | |
<button | |
type="submit" | |
class="btn btn-primary" | |
> | |
Save <i class="fas fa-save"/> | |
</button> | |
</div> | |
</form> | |
</div> | |
</div> | |
</div> | |
</div> | |
</template> | |
<script> | |
import Vuetable from 'vuetable-2' | |
// Bootstrap 4 style pagination | |
import VuetablePagination from '../Vuetable/VuetablePaginationBootstrap' | |
import VuetablePaginationDropdown from '../Vuetable//VuetablePaginationDropdownBootstrap' | |
import VuetableCss from '../Vuetable/VuetableCss' | |
import fieldsDef from './usersFieldsDef' | |
import VuetableFilterBar from '../Vuetable/VuetableFilterBarBootstrap' | |
export default { | |
components: { | |
Vuetable, // The vuetable | |
VuetablePagination, // nav/button based pagination | |
VuetablePaginationDropdown, // select based pagination | |
VuetableFilterBar // Search | |
}, | |
data () { | |
return { | |
css: VuetableCss, // Reusable Css | |
fields: fieldsDef, // Field Definitions / rendering | |
sortOrder: [{ | |
field: 'name', | |
direction: 'asc' | |
}], | |
data: [], // Table data | |
user: {}, // Current user | |
roles: {}, // All of the available roles | |
newRoles: [], // List of roles to be synced when submitted | |
moreParams: {} // Sends the filter with the other params | |
} | |
}, | |
mounted () { | |
// Listen for the filter events from VuetableFilterBar | |
this.$events.$on('filter-set', eventData => this.onFilterSet(eventData)) | |
this.$events.$on('filter-reset', e => this.onFilterReset()) // eslint-disable-line no-unused-vars | |
this.getRoles() // Get all the available roles | |
}, | |
methods: { | |
/* Search Filter Handling */ | |
onFilterSet (filterText) { | |
// console.log('filter-set', filterText) | |
this.moreParams = { | |
filter: filterText | |
} | |
// console.log(this.$refs.vuetable) | |
Vue.nextTick( () => this.$refs.vuetable.refresh() ) // eslint-disable-line no-undef | |
}, | |
onFilterReset () { | |
// console.log('filter-reset') | |
this.moreParams = {} | |
Vue.nextTick( () => this.$refs.vuetable.refresh() ) // eslint-disable-line | |
}, | |
// Show the modal with the roles of the current user | |
addRoles(user) { | |
this.user = user | |
// Create the non-indexed array "newRoles" | |
this.newRoles = this.user.roles.map((role) => { | |
return role.name | |
}) | |
$('#rolesModal').modal('show') // eslint-disable-line no-undef | |
}, | |
// Fetch all of the avilable roles that can be used | |
getRoles: function() { | |
this.axios.get('/admin/roles') // eslint-disable-line no-undef | |
.then(({ data }) => { | |
this.roles = data.data | |
}) | |
}, | |
// replaces the vuetales own call to api-url to handle jwt auth | |
getUsers(apiUrl, httpOptions) { | |
return this.$http.get(apiUrl, httpOptions) | |
}, | |
// Save the response from the modal form using Laravel resource controller | |
saveRoles: function() { | |
axios.put('/admin/users/'+this.user.id, { // eslint-disable-line no-undef | |
params: { | |
query: { | |
user: this.user, | |
roles: this.newRoles | |
} | |
} | |
}).then(() => { | |
// Reload the table and hide the modal | |
this.$refs.vuetable.reload() // Reload keeps the current page number | |
$('#rolesModal').modal('hide') // eslint-disable-line no-undef | |
}) // Do we need a catch? | |
}, | |
// Trigger the pagination changes | |
onPaginationData (paginationData) { | |
this.$refs.pagination.setPaginationData(paginationData) | |
this.$refs.paginationDropdown.setPaginationData(paginationData) | |
}, | |
onChangePage (page) { | |
this.$refs.vuetable.changePage(page) | |
} | |
} | |
} | |
</script> | |
<style> | |
/* .vuetable-th-checkbox-id { | |
width: 2%; | |
} | |
.vuetable-th-id { | |
width: 8%; | |
} */ | |
.vuetable-th-username { | |
width: 20%; | |
} | |
.vuetable-th-name { | |
width: 20%; | |
} | |
.vuetable-th-email { | |
width: 15%; | |
} | |
.vuetable-th-slot-roles { | |
width: 65%; | |
} | |
.vuetable-pagination nav { | |
float: right; | |
} | |
.vuetable-pagination-dropdown nav { | |
float: right; | |
} | |
</style> |
// Deals with the sort icons using fontawesome | |
export default { | |
tableClass: 'table', | |
loadingClass: 'loading', | |
ascendingIcon: 'fas fa-arrow-down', | |
descendingIcon: 'fas fa-arrow-up', | |
detailRowClass: 'vuetable-detail-row', | |
handleIcon: 'fas fa-bars', | |
sortableIcon: '', //fas fa-sort-amount-up', // since v1.7 | |
ascendingClass: 'sorted-asc', // since v1.7 | |
descendingClass: 'sorted-desc' // since v1.7 | |
} |
<template> | |
<div class="vuetable-filter-bar"> | |
<div class="form-inline"> | |
<div class="form-group"> | |
<label class="sr-only">Search for: </label> | |
<div class="input-group"> | |
<div class="input-group-prepend"> | |
<span class="input-group-btn"> | |
<button | |
class="btn btn-primary" | |
type="button" | |
@click="doFilter" | |
> | |
<i class="fas fa-search"/> | |
</button> | |
</span> | |
</div> | |
<input | |
v-model="filterText" | |
class="form-control" | |
placeholder="Search string" | |
type="text" | |
@keyup.enter="doFilter" | |
> | |
<div class="input-group-append"> | |
<span class="input-group-btn"> | |
<button | |
class="btn btn-default" | |
type="button" | |
@click="resetFilter" | |
> | |
<i class="fas fa-times"/> | |
</button> | |
</span> | |
</div> | |
</div> | |
</div> | |
</div> | |
</div> | |
</template> | |
<script> | |
export default { | |
data () { | |
return { | |
filterText: '' | |
} | |
}, | |
methods: { | |
doFilter () { | |
this.$events.fire('filter-set', this.filterText) | |
}, | |
resetFilter () { | |
this.filterText = '' | |
this.$events.fire('filter-reset') | |
} | |
} | |
} | |
</script> |
<template> | |
<div class="vuetable-pagination"> | |
<nav aria-label="Table Navigation"> | |
<ul class="pagination"> | |
<li | |
:class="{'disabled': isOnFirstPage}" | |
class="page-item"> | |
<a | |
class="page-link" | |
href="#" | |
@click.prevent="loadPage(1)"> | |
<span><i class="fas fa-angle-double-left"/></span> | |
</a> | |
</li> | |
<li | |
:class="{'disabled': isOnFirstPage}" | |
class="page-item"> | |
<a | |
class="page-link" | |
href="#" | |
@click.prevent="loadPage('prev')"> | |
<span><i class="fas fa-angle-left"/></span> | |
</a> | |
</li> | |
<template v-if="notEnoughPages"> | |
<li | |
v-for="n in totalPage" | |
:key="n" | |
:class="{'active': isCurrentPage(n)}" | |
class="page-item" | |
> | |
<a | |
class="page-link" | |
@click.prevent="loadPage(n)" | |
v–html="n"/> | |
</li> | |
</template> | |
<template v–else> | |
<li | |
v-for="n in windowSize" | |
:key="n" | |
:class="{'active': isCurrentPage(windowStart+n-1)}" | |
class="page-item" | |
> | |
<a | |
class="page-link" | |
@click.prevent="loadPage(windowStart+n–1)" | |
v–html="windowStart+n-1"/> | |
</li> | |
</template> | |
<li | |
:class="{'disabled': isOnLastPage}" | |
class="page-item" | |
> | |
<a | |
class="page-link" | |
href="" | |
@click.prevent="loadPage('next')"> | |
<span><i class="fas fa-angle-right"/></span> | |
</a> | |
</li> | |
<li | |
:class="{'disabled': isOnLastPage}" | |
class="page-item" | |
> | |
<a | |
class="page-link" | |
href="" | |
@click.prevent="loadPage(totalPage)"> | |
<span><i class="fas fa-angle-double-right"/></span> | |
</a> | |
</li> | |
</ul> | |
</nav> | |
</div> | |
</template> | |
<script> | |
import { VuetablePaginationMixin } from 'vuetable-2' | |
export default { | |
mixins: [VuetablePaginationMixin] | |
} | |
</script> |
<template> | |
<div class="vuetable-pagination-dropdown"> | |
<nav aria-label="Table Navigation"> | |
<ul class="pagination"> | |
<li | |
:class="{'disabled': isOnFirstPage}" | |
class="page-item"> | |
<a | |
class="page-link" | |
href="#" | |
@click.prevent="loadPage(1)"> | |
<span><i class="fas fa-angle-double-left"/></span> | |
</a> | |
</li> | |
<li | |
:class="{'disabled': isOnFirstPage}" | |
class="page-item"> | |
<a | |
class="page-link" | |
href="#" | |
@click.prevent="loadPage('prev')"> | |
<span><i class="fas fa-angle-left"/></span> | |
</a> | |
</li> | |
<div class="form-group mr-1 ml-1"> | |
<select | |
id="paginationDropdown" | |
class="form-control" | |
@change="loadPage($event.target.selectedIndex+1)" | |
> | |
<template | |
v-for="k in totalPage" | |
> | |
<option | |
:key="k" | |
:value="k" | |
:selected="isCurrentPage(k)" | |
> | |
Page {{ k }} | |
</option> | |
</template> | |
</select> | |
</div> | |
<li | |
:class="{'disabled': isOnLastPage}" | |
class="page-item" | |
> | |
<a | |
class="page-link" | |
href="" | |
@click.prevent="loadPage('next')"> | |
<span><i class="fas fa-angle-right"/></span> | |
</a> | |
</li> | |
<li | |
:class="{'disabled': isOnLastPage}" | |
class="page-item" | |
> | |
<a | |
class="page-link" | |
href="" | |
@click.prevent="loadPage(totalPage)"> | |
<span><i class="fas fa-angle-double-right"/></span> | |
</a> | |
</li> | |
</ul> | |
</nav> | |
</div> | |
</template> | |
<script> | |
import { VuetablePaginationMixin } from 'vuetable-2' | |
export default { | |
mixins: [VuetablePaginationMixin] | |
} | |
</script> |