`I have 3 components for a table. In my employees table, when i click the next button in the bottom of the table, it sends two request, one with the next page url, and simultaneously it sends the request with the previous page url, its driving me crazy. I dont find the place where it is being called twice the request. It behaves like the next and previous button are being pressed at the same time. I removed some style for this to fit the characters maximum
First component
<template>
<v-container>
<v-row class="justify-start pt-4 px-4">
<v-col class="d-flex align-center mb-5" cols="9">
<v-icon class="mr-2" color="#C9C6FF">mdi-filter-variant</v-icon>
<p style="color: #C9C6FF; font-size: 1.25rem; margin-right: 1rem">Filtros</p>
<v-btn v-for="sf in statusFilters" v-if="hasQuickFilters" :key="sf.value"
:class="{ 'selected-icon': sf.isSelected }" :size="isNotMobile? 'medium': 'x-small'"
:value="sf.value" :variant="sf.isSelected ? 'flat' : 'outlined'" class="mx-2 filter-btn"
rounded="lg" @click="setFilter(sf)">{{ sf.label }}
</v-btn>
<v-btn v-if="hasQuickFilters || hasFilters" :size="isNotMobile? 'x-large': 'small'" class="ml-n3"
variant="text" @click="clearFilters">
<v-icon size="large">mdi-backspace-outline</v-icon>
</v-btn>
<v-btn v-if="!showSearchbar" :size="isNotMobile? 'x-large': 'small'" class="ml-n3"
transition="scroll-x-transition" variant="text"
@click="showSearchbar = !showSearchbar">
<v-icon>mdi-magnify</v-icon>
</v-btn>
<v-slide-x-transition>
<v-text-field
v-if="showSearchbar"
v-model="search"
density="compact"
fluid
hide-details
label="Buscar"
prepend-inner-icon="mdi-magnify"
rounded="lg"
single-line
transition="scroll-x-transition"
variant="outlined"
@input="searchFilter({value: search, key: 'search', icon: 'mdi-magnify', isSelected: false})"
>
<template v-slot:append-inner>
<v-icon @click="showSearchbar = !showSearchbar">mdi-close</v-icon>
</template>
</v-text-field>
</v-slide-x-transition>
</v-col>
<!-- <v-col cols="3" class="justify-center">-->
<!-- <v-slide-x-transition>-->
<!-- <v-text-field-->
<!-- v-if="showSearchbar"-->
<!-- v-model="search"-->
<!-- density="compact"-->
<!-- hide-details-->
<!-- label="Buscar"-->
<!-- prepend-inner-icon="mdi-magnify"-->
<!-- rounded="lg"-->
<!-- single-line-->
<!-- transition="scroll-x-transition"-->
<!-- variant="outlined"-->
<!-- @input="searchFilter({value: search, key: 'search', icon: 'mdi-magnify', isSelected: false})"-->
<!-- >-->
<!-- <template v-slot:append-inner>-->
<!-- <v-icon @click="showSearchbar = !showSearchbar">mdi-close</v-icon>-->
<!-- </template>-->
<!-- </v-text-field>-->
<!-- </v-slide-x-transition>-->
<!-- </v-col>-->
</v-row>
<v-row class="mt-0">
<v-col :cols="tableCols">
<v-data-table-server
:headers="headers"
:items="items"
:items-length="itemsLength"
:items-per-page="pageSize"
:items-per-page-options="[10, 25, 50, 100]"
:loading="loader"
:mobile="false"
:show-current-page="true"
:style="{ height: `calc(100vh - 234px)`, backgroundColor: '#1B1B1B', color: 'white'}"
disable-sort
first-icon="mdi-user"
fixed-footer
items-per-page-text=""
no-data-text="No hay datos disponibles"
search=""
@update:options="loadItems">
<template v-slot:item.is_active="{ item }: { item: ListItem }">
<v-chip v-if="item.is_active" color="green">Si</v-chip>
<v-chip v-else color="red">No</v-chip>
</template>
<template v-slot:item.status="{ item }: { item: ListItem }">
<v-chip v-if="item.status" color="green">En uso</v-chip>
<v-chip v-else color="red">Inactivo</v-chip>
</template>
<template v-slot:item.firstCol="{ item }: { item: ListItem }">
<v-btn color="#6C6CF1" style="min-width: 100px;" variant="outlined" @click="goToEdit(item)">{{
item[keyName]
}}
</v-btn>
</template>
<template v-slot:item.permissions="{ item }: { item: ListItem }">
<v-list class="d-flex">
<v-list-item
v-for="n in item.permissions"
:key="n"
:title="n"
></v-list-item>
</v-list>
</template>
<template v-slot:item.created="{ item }: { item: ListItem }">
{{ parseTimeUTCVE(item.created) }}
</template>
<template v-slot:item.access_point="{ item }: { item: ListItem }">
{{ item.access_point }} - {{ parseTimeUTCVE(item.entry_time) }}
</template>
<template v-slot:item.exit_point="{ item }: { item: ListItem }">
{{ item.exit_point }} - {{ parseTimeUTCVE(item.exit_time) }}
</template>
<template v-slot:item.cash_balance="{ item }: { item: ListItem }">
USD {{ item.cash_balance["USD"] }} - BS {{ item.cash_balance.BS }}
</template>
</v-data-table-server>
</v-col>
<v-slide-x-transition>
<v-col v-if="showFilters" :cols="filterCols"
:style="{ height: `calc(100vh - 168px)`, overflowY: 'auto', borderLeft: '1px rgb(128,128,128) solid' }">
<h3>Filtros</h3>
<v-row>
<v-col>
<slot/>
</v-col>
</v-row>
</v-col>
</v-slide-x-transition>
</v-row>
</v-container>
</template>
<script lang="ts">
import type {Filter, ListItem} from "@/types";
import {debounce} from "lodash";
import {parseTimeUTCVE} from "@/utils";
import {useDisplay} from "vuetify";
export default {
props: {
headers: {
type: Array<object>,
required: true
},
items: {
type: Array<ListItem>,
required: true
},
pageSize: {
type: Number,
required: true
},
loader: {
type: Boolean,
required: true
},
hasFilters: {
type: Boolean,
required: true
},
hasQuickFilters: {
type: Boolean,
default: true
},
keyName: {
type: String,
required: true
},
editKey: {
type: String,
required: true
},
itemsLength: {
type: Number,
required: true
},
statusFilters: {
type: Array<Filter>,
required: true
},
filterSelected: {
type: Boolean,
required: false
},
},
emits: ["submit", "create", "update-table", "set-filter", "clear-filters"],
data() {
return {
fab: false,
selectedStatusFilter: false,
showFilters: false,
areFiltersApplied: false,
search: '',
tableCols: 12,
filterCols: 0,
showSearchbar: false,
filterIcon: 'mdi-filter-menu'
}
},
computed: {
isNotMobile() {
const {mdAndUp} = useDisplay()
return mdAndUp.value
}
},
methods: {
parseTimeUTCVE,
goToEdit(item: object) {
this.$emit('submit', item)
},
goToCreate() {
this.$emit('create')
},
loadItems(options: any) {
const page = options.page
const itemsPerPage = options.itemsPerPage
const sortBy = options.sortBy
this.$emit('update-table', {page, itemsPerPage, sortBy});
},
setFilter(filter: Filter) {
filter.isSelected = !filter.isSelected
this.statusFilters.forEach((sf) => {
if (sf !== filter) {
sf.isSelected = false;
}
});
this.$emit('set-filter', filter)
},
searchFilter: debounce(function (this: any, filter: Filter) {
filter.isSelected = !filter.isSelected
this.$emit('set-filter', filter)
}, 700),
toggleFilters() {
this.showFilters = !this.showFilters
this.filterCols = this.showFilters ? 4 : 0
this.tableCols = this.showFilters ? 8 : 12
this.filterIcon = this.showFilters ? 'mdi-filter-remove' : 'mdi-filter-menu'
},
clearFilters() {
this.$emit('clear-filters');
}
},
}
</script>
</style>
Second component
<template>
<v-row>
<v-col class="d-flex justify-end" cols="12">
<v-btn class="actions-buttons" color="#6C6CF1" style="min-width: 100px;" variant="outlined">
<v-icon class="mr-3">mdi-delete</v-icon>
Eliminar
</v-btn>
<v-btn class="actions-buttons" color="#6C6CF1" style="min-width: 100px;" variant="outlined"
@click="goToCreate()">
<v-icon class="mr-3">mdi-plus-circle-outline</v-icon>
Crear
</v-btn>
<v-btn class="actions-buttons" color="#6C6CF1" style="min-width: 120px;" variant="outlined">
<v-icon>mdi-folder-plus</v-icon>
Carga Masiva
</v-btn>
<v-btn class="actions-buttons" color="#6C6CF1" style="min-width: 120px;" variant="outlined">
<v-icon class="mr-2">mdi-download</v-icon>
Descargar
</v-btn>
</v-col>
</v-row>
<v-card :style="{ height: `calc(100vh - 100px)`, overflowY: 'auto' }" class="my-4" color="#1B1B1B"
elevation="1" rounded="lg">
<InnerTable
:edit-key="editKey"
:filter-selected="filterSelected"
:has-filters="hasFilters"
:has-quick-filters="hasQuickFilters"
:headers="headers"
:items="items"
:items-length="itemsLength"
:key-name="keyName"
:loader="loader"
:page-size="pageSize"
:status-filters="statusFilters"
@create="goToCreate"
@submit="goToEdit"
@update-table="updateTable"
@set-filter="setFilter"
@clear-filters="clearFilters"
>
<template v-slot:default>
<slot></slot>
</template>
</InnerTable>
</v-card>
</template>
<script lang="ts">
import InnerTable from './InnerTable.vue';
import type {Filter, ListItem} from "@/types";
import {useDisplay} from "vuetify";
export default {
name: 'ListTable',
components: {
InnerTable
},
props: {
headers: {
type: Array<object>,
required: true
},
items: {
type: Array<ListItem>,
required: true
},
disabledFab: {
type: Boolean,
required: true
},
pageSize: {
type: Number,
required: true
},
loader: {
type: Boolean,
required: true
},
hasFilters: {
type: Boolean,
required: true
},
hasQuickFilters: {
type: Boolean,
default: true
},
keyName: {
type: String,
required: true
},
editKey: {
type: String,
required: true
},
itemsLength: {
type: Number,
required: true
},
statusFilters: {
type: Array<Filter>,
required: true
},
filterSelected: {
type: Boolean,
required: false
},
},
emits: ["submit", "create", "update-table", "set-filter", "clear-filters"],
data() {
return {
fab: false,
selectedStatusFilter: false,
showFilters: false,
areFiltersApplied: false,
search: '',
tableCols: 12,
filterCols: 0,
showSearchbar: false,
tableHeight: '',
filterIcon: 'mdi-filter-menu',
hideFooter: false,
}
},
computed: {
isNotMobile() {
const {mdAndUp} = useDisplay()
return mdAndUp.value
}
},
methods: {
goToEdit(item: object) {
this.$emit('submit', item)
},
goToCreate() {
this.$emit('create')
},
updateTable(options: any) {
this.$emit('update-table', options);
},
setFilter(filter: Filter) {
this.$emit('set-filter', filter)
},
clearFilters() {
this.$emit('clear-filters');
}
},
}
</script>
<style scoped>
@media only screen and (min-width: 760px) {
::v-deep .v-data-table-footer {
align-items: center;
display: flex;
flex-wrap: wrap;
justify-content: center;
padding: 8px 4px;
}
}
.actions-buttons {
width: 8vw;
text-transform: capitalize;
margin-left: 5px;
margin-right: 5px;
border-radius: 10px;
}
</style>
Third component
<template>
<list-table
:current-page="page"
:disabled-fab="showFab" :filter-selected="filterSelected"
:has-filters="true"
:headers="getColumns" :items="users"
:items-length="usersCount"
:loader="loader"
:page-size="pageSize"
:status-filters="statusFilters"
edit-key="id"
key-name="dni"
@create="goToCreate()"
@submit="goToEdit($event)"
@update-table="updateTable($event)"
@set-filter="setFilter"
@clear-filters="clearFilter"
>
<v-select v-model="company" :items="companies"
clearable density="compact"
item-title="name" item-value="name" label="Empresa" rounded="xl" variant="outlined"/>
</list-table>
</template>
<script lang="ts">
import ListTable from '@/components/ListTable.vue'
import {userStore} from '@/stores/user'
import type {baseObject, BreadcrumbsArray, Filter, PaginationProps, User} from "@/types";
import {miscStore} from "@/stores/runtime";
import {useDisplay} from "vuetify";
import {companyStore} from "@/stores/company";
import {errorHandling} from "@/utils/error";
import type {AxiosError} from "axios";
export default {
name: 'Employees',
components: {ListTable},
data() {
return {
miscStore: miscStore(),
userStore: userStore(),
companyStore: companyStore(),
loader: true,
showFab: true,
page: 1,
pageSize: 10,
state: null,
params: {},
isRequesting: false,
parking: null,
company: null,
filterSelected: false,
clearingFilter: false,
columns: [
{
title: 'DNI',
align: 'start',
key: 'firstCol'
},
{
title: 'Empresa',
align: 'start',
key: 'company'
},
{
title: 'Usuario',
align: 'start',
key: 'username'
},
{
title: 'Nombre',
align: 'start',
key: 'full_name'
},
{
title: 'Roles',
align: 'start',
key: 'roles'
},
{
title: 'Activo',
align: 'start',
key: 'is_active'
},
],
statusFilters: [
{key: 'is_active', value: true, label: 'Activo', isSelected: false},
{key: 'is_active', value: false, label: 'Inactivo', isSelected: false},
],
}
},
async created() {
await this.companyStore.fetchCompanies(1, {}, 100)
this.userStore.fetchUserPermissions()
if (this.userPermissions.create === false) {
this.showFab = false
}
},
computed: {
users() {
return this.userStore.getUsers
},
companies() {
return this.companyStore.getCompanies
},
userPermissions() {
return this.userStore.getUserPermissions
},
isNotMobile() {
const {mdAndUp} = useDisplay()
return mdAndUp.value
},
getColumns() {
const columnsMobile = ['Cédula', 'Nombre de usuario', 'Activo']
if (this.isNotMobile) {
return this.columns
} else {
return this.columns.filter(column => columnsMobile.includes(column.title))
}
},
usersCount() {
return this.userStore.getUsersCount
},
breadcrumbs: {
get() {
return this.miscStore.getBreadcrumbs;
},
set(newBreadcrumbs: BreadcrumbsArray) {
// Perform any validation or logic before setting
this.miscStore.setBreadcrumbs(newBreadcrumbs);
}
}
},
methods: {
async goToEdit(user: User) {
this.loader = true
this.breadcrumbs.push({title: user.username, to: '/update-employee'})
this.miscStore.setBreadcrumbs(this.breadcrumbs)
await this.userStore.fetchEmployeeById(user.id)
this.$router.push('/update-employee')
},
goToCreate() {
this.breadcrumbs.push({title: "Creación de empleado", to: '/create-employee'})
this.miscStore.setBreadcrumbs(this.breadcrumbs)
this.$router.push('/create-employee')
},
async fetchEmployees(page: number, query_params: object, pageSize: number) {
this.loader = true
await this.userStore.fetchEmployees(page, query_params, pageSize).catch((error: AxiosError) => {
errorHandling(error)
})
this.loader = false
},
async updateTable(tableProps: PaginationProps) {
await this.fetchEmployees(tableProps.page, this.params, tableProps.itemsPerPage)
},
async setFilter(filter: Filter) {
if (filter.value === (this.params as baseObject)[filter.key]) {
delete (this.params as baseObject)[filter.key];
} else {
(this.params as baseObject)[filter.key] = filter.value;
}
await this.fetchEmployees(1, this.params, this.pageSize);
},
async clearFilter() {
this.clearingFilter = true
this.params = {}
this.statusFilters = this.statusFilters.map(item => ({...item, isSelected: false}));
this.clearingFilter = false
await this.fetchEmployees(1, this.params, this.pageSize);
},
},
unmounted() {
this.userStore.clearUsers()
},
}
</script>
<style scoped>
</style>
Store
import {acceptHMRUpdate, defineStore} from 'pinia'
import {AxiosDelete, AxiosGet, AxiosLogin, AxiosPatch, AxiosPost, AxiosPut, ServiceUrls} from '@/plugins/axios'
import type {ListItem, LoginResponse, Nullable, PermissionsBasicInfo, User} from '@/types'
import {parsePermissions} from "@/utils/parsePermissions";
export const userStore = defineStore('user', {
state: () => ({
firstName: '',
middleName: '' as Nullable<string>,
lastName: '',
users: [] as Array<ListItem>,
roles: [] as Array<ListItem>,
accountOperations: [] as Array<ListItem>,
user: {} as User,
account: {} as User,
usersCount: 0 as number,
operationsCount: 0 as number,
isLoggedIn: false,
code: 0,
username: '' as string,
accountPermissions: {} as PermissionsBasicInfo,
userPermissions: {} as PermissionsBasicInfo,
employeesDropdown: [] as Array<ListItem>,
}),
getters: {
getFirstName: (state) => state.firstName,
getIsLoggedIn: (state) => state.isLoggedIn,
getUsers: (state) => state.users,
getUser: (state) => state.user,
getUsername: (state) => state.username,
getUsersCount: (state) => state.usersCount,
getRoles: (state) => state.roles,
getAccount: (state) => state.account,
getAccountPermissions: (state) => state.accountPermissions,
getUserPermissions: (state) => state.userPermissions,
getEmployeesDropdown: (state) => state.employeesDropdown
},
actions: {
loginUser(username: string, password: string) {
return AxiosLogin(ServiceUrls.LOGIN, {username, password})
},
async logoutUser() {
let url = ServiceUrls.USERS + 'logout/'
this.setLoggedInState(false);
localStorage.removeItem('permissions')
localStorage.removeItem('token')
await AxiosDelete(url)
},
async fetchEmployees(page: number, baseParams: object, pageSize: number) {
this.usersCount = 0
let url = ServiceUrls.EMPLOYEE
const params = {
page,
page_size: pageSize,
...baseParams
}
let response = await AxiosGet(url, params)
this.users = response.results
this.usersCount = response.count
},
async fetchDropDownEmployees() {
let url = ServiceUrls.DROPDOWN_EMPLOYEES
this.employeesDropdown = await AxiosGet(url)
},
async fetchAccounts(page: number, baseParams: object, pageSize: number) {
this.usersCount = 0
let url = ServiceUrls.ACCOUNTS
const params = {
page,
page_size: pageSize,
...baseParams
}
const response = await AxiosGet(url, params)
this.users = response.results
this.usersCount = response.count
},
async fetchEmployeeById(id: string) {
let url = ServiceUrls.EMPLOYEE + id + '/'
this.user = await AxiosGet(url)
localStorage.setItem('user', JSON.stringify(this.user));
},
async fetchAccountByUsername(username: string) {
let url = ServiceUrls.ACCOUNTS + username + '/'
this.account = await AxiosGet(url)
localStorage.setItem('account', JSON.stringify(this.account));
},
async fetchAccountOperations(page: number, username: string, filters: object, pageSize: number) {
let url = ServiceUrls.USERS + username + '/operations/'
this.usersCount = 0
const params = {
page,
page_size: pageSize,
...filters
}
const response = await AxiosGet(url, params)
this.accountOperations = response.results
this.operationsCount = response.count
},
async recharge(data: object, username: string) {
let url = ServiceUrls.RECHARGE
let baseUrl = url.replace(':username', username)
return await AxiosPost(baseUrl, data)
},
async createEmployee(data: object) {
let url = ServiceUrls.EMPLOYEE
return await AxiosPost(url, data)
},
async createAccount(data: object) {
let url = ServiceUrls.ACCOUNTS
return await AxiosPost(url, data)
},
async updateUser(data: object, id: string) {
let url = ServiceUrls.EMPLOYEE + id + '/'
return await AxiosPatch(url, data)
},
async updateEmployee(data: object, username: string) {
let url = ServiceUrls.EMPLOYEE + username + '/'
return await AxiosPut(url, data)
},
async patchUser(data: object, username: string) {
let url = ServiceUrls.USERS + username + '/'
return await AxiosPatch(url, data)
},
async deleteUser(username: string) {
let url = ServiceUrls.EMPLOYEE + username + '/'
return await AxiosDelete(url)
},
setUserData(data: LoginResponse) {
if (data) {
// this.username = data.username
this.setLoggedInState(true);
const permissions = JSON.stringify(data.user.permissions)
localStorage.setItem('permissions', permissions)
localStorage.setItem('token', data.token)
// localStorage.setItem('username', this.username)
} else {
throw new Error('Credenciales inválidas')
}
},
clearUsers() {
this.users = []
},
setLoggedInState(isLoggedIn: boolean) {
this.isLoggedIn = isLoggedIn;
localStorage.setItem('isLoggedIn', JSON.stringify(isLoggedIn));
},
initLoggedInState() {
const loggedInState = localStorage.getItem('isLoggedIn');
const username = localStorage.getItem('username')
if (loggedInState) {
this.isLoggedIn = JSON.parse(loggedInState);
} else {
this.isLoggedIn = false
}
if (username) {
this.username = username
}
},
initEmployee() {
const user = localStorage.getItem('user');
if (user) {
this.user = JSON.parse(user);
}
},
clearEmployee() {
this.user = {} as User
localStorage.removeItem('user')
},
initAccount() {
const account = localStorage.getItem('account');
if (account) {
this.account = JSON.parse(account);
}
},
clearAccountOperations() {
this.accountOperations = []
},
fetchAccountPermissions() {
this.accountPermissions = parsePermissions('account')
},
fetchUserPermissions() {
this.userPermissions = parsePermissions('employee')
}
}
})
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(userStore, import.meta.hot))
}
Juan Eduardo Escobar is a new contributor to this site. Take care in asking for clarification, commenting, and answering.
Check out our Code of Conduct.
1