mirror of
https://git.hmsn.ink/kospo/svcm/oa.git
synced 2026-03-20 08:03:34 +09:00
484 lines
14 KiB
Markdown
484 lines
14 KiB
Markdown
### Async data fetching (advanced)
|
|
|
|
Most of the time, the data need to be fetched asynchronously from the server.
|
|
Thankfully, the `data` property of `<VFlexTableWrapper />` component can accept
|
|
an **async function** that will be triggered each time one of the sorting,
|
|
filtering or pagination properties change.
|
|
Check the markup for more details about usage.
|
|
|
|
<!--code-->
|
|
|
|
```vue
|
|
<script setup lang="ts">
|
|
import type { VFlexTableWrapperDataResolver } from '/@src/components/base/table/VFlexTableWrapper.vue'
|
|
|
|
// the total data will be set by the fetchData function
|
|
const total = ref(0)
|
|
|
|
// we don't have to set "searchable" parameter
|
|
// this will be handled by the fetchData function
|
|
const columns = {
|
|
name: {
|
|
label: 'Username',
|
|
media: true,
|
|
grow: true,
|
|
sortable: true,
|
|
},
|
|
location: {
|
|
label: 'Location',
|
|
sortable: true,
|
|
},
|
|
position: {
|
|
label: 'Positions',
|
|
sortable: true,
|
|
},
|
|
actions: {
|
|
label: '',
|
|
align: 'end',
|
|
},
|
|
} as const
|
|
|
|
// this is an example of useXxx function that we can reuse across components.
|
|
// it will return writable computeds that works like ref values
|
|
// but the values will be sync with the route query params
|
|
function useQueryParam() {
|
|
const router = useRouter()
|
|
const route = useRoute()
|
|
|
|
// when the params match those value,
|
|
// we don't set their value to the query params
|
|
const defaultSearch = ''
|
|
const defaultSort = ''
|
|
const defaultLimit = 10
|
|
const defaultPage = 1
|
|
|
|
const searchTerm = computed({
|
|
get: () => {
|
|
let searchTermQuery: string
|
|
|
|
// read "search" from the query params
|
|
if (Array.isArray(route?.query?.search)) {
|
|
searchTermQuery = route.query.search?.[0] ?? defaultSearch
|
|
}
|
|
else {
|
|
searchTermQuery = route.query.search ?? defaultSearch
|
|
}
|
|
|
|
return searchTermQuery
|
|
},
|
|
set(value) {
|
|
// update the route query params with our new "search" value.
|
|
// we can use router.replace instead of router.push
|
|
// to not write state to the browser history
|
|
router.push({
|
|
query: {
|
|
search: value === defaultSearch ? undefined : value,
|
|
sort: sort.value === defaultSort ? undefined : sort.value,
|
|
limit: limit.value === defaultLimit ? undefined : limit.value,
|
|
page: page.value === defaultPage ? undefined : page.value,
|
|
},
|
|
})
|
|
},
|
|
})
|
|
|
|
const sort = computed({
|
|
get: () => {
|
|
let sortQuery: string
|
|
|
|
// read "sort" from the query params
|
|
if (Array.isArray(route?.query?.sort)) {
|
|
sortQuery = route.query.sort?.[0] ?? defaultSort
|
|
}
|
|
else {
|
|
sortQuery = route.query.sort ?? defaultSort
|
|
}
|
|
|
|
return sortQuery
|
|
},
|
|
set(value) {
|
|
// update the route query params with our new "sort" value.
|
|
// we can use router.replace instead of router.push
|
|
// to not write state to the browser history
|
|
router.push({
|
|
query: {
|
|
search: searchTerm.value === defaultSearch ? undefined : searchTerm.value,
|
|
sort: value === defaultSort ? undefined : value,
|
|
limit: limit.value === defaultLimit ? undefined : limit.value,
|
|
page: page.value === defaultPage ? undefined : page.value,
|
|
},
|
|
})
|
|
},
|
|
})
|
|
|
|
const limit = computed({
|
|
get: () => {
|
|
let limitQuery: number
|
|
|
|
// read "limit" from the query params
|
|
if (Array.isArray(route?.query?.limit)) {
|
|
limitQuery = parseInt(route.query.limit[0] ?? `${defaultLimit}`)
|
|
}
|
|
else {
|
|
limitQuery = parseInt(route.query.limit ?? `${defaultLimit}`)
|
|
}
|
|
|
|
if (Object.is(limitQuery, NaN)) {
|
|
limitQuery = defaultLimit
|
|
}
|
|
|
|
return limitQuery
|
|
},
|
|
set(value) {
|
|
// update the route query params with our new "limit" value.
|
|
// we can use router.replace instead of router.push
|
|
// to not write state to the browser history
|
|
router.push({
|
|
query: {
|
|
search: searchTerm.value === defaultSearch ? undefined : searchTerm.value,
|
|
sort: sort.value === defaultSort ? undefined : sort.value,
|
|
limit: value === defaultLimit ? undefined : value,
|
|
page: page.value === defaultPage ? undefined : page.value,
|
|
},
|
|
})
|
|
},
|
|
})
|
|
|
|
const page = computed({
|
|
get: () => {
|
|
let pageQuery: number
|
|
|
|
if (Array.isArray(route?.query?.page)) {
|
|
pageQuery = parseInt(route.query.page[0] ?? `${defaultPage}`)
|
|
}
|
|
else {
|
|
pageQuery = parseInt(route.query.page ?? `${defaultPage}`)
|
|
}
|
|
|
|
// read "page" from the query params
|
|
if (Object.is(pageQuery, NaN)) {
|
|
pageQuery = defaultPage
|
|
}
|
|
|
|
return pageQuery
|
|
},
|
|
set(value) {
|
|
// update the route query params with our new "page" value.
|
|
// we can use router.replace instead of router.push
|
|
// to not write state to the browser history
|
|
router.push({
|
|
query: {
|
|
search: searchTerm.value === defaultSearch ? undefined : searchTerm.value,
|
|
sort: sort.value === defaultSort ? undefined : sort.value,
|
|
limit: limit.value === defaultLimit ? undefined : limit.value,
|
|
page: value === defaultPage ? undefined : value,
|
|
},
|
|
})
|
|
},
|
|
})
|
|
|
|
return reactive({
|
|
searchTerm,
|
|
sort,
|
|
limit,
|
|
page,
|
|
})
|
|
}
|
|
const queryParam = useQueryParam()
|
|
|
|
// the fetchData function will be called each time one of the parameter changes
|
|
const $fetch = useApiFetch()
|
|
const fetchData: VFlexTableWrapperDataResolver = async ({
|
|
searchTerm,
|
|
start,
|
|
limit,
|
|
sort,
|
|
controller,
|
|
}) => {
|
|
// sort will be a string like "name:asc"
|
|
let [sortField, sortOrder]
|
|
= sort && sort.includes(':') ? sort.split(':') : [undefined, undefined]
|
|
|
|
// async fetch data to our server
|
|
const { _data: users, headers } = await $fetch.raw(`/api/users`, {
|
|
query: {
|
|
// searchTerm will contains the value of the wrapperState.searchInput
|
|
// the update will be debounced to avoid to much requests
|
|
q: searchTerm,
|
|
_start: start,
|
|
_limit: limit,
|
|
_sort: sortField,
|
|
_order: sortOrder,
|
|
},
|
|
// controller is an instance of AbortController,
|
|
// this allow to abort the request when the state
|
|
// is invalidated (before fetchData will be retriggered)
|
|
signal: controller?.signal,
|
|
})
|
|
|
|
// wait more time
|
|
await sleep(1000)
|
|
|
|
// our backend send us the count in the headers,
|
|
// but we can also get it from another request
|
|
if (headers.has('X-Total-Count')) {
|
|
total.value = parseInt(headers.get('X-Total-Count') ?? '0')
|
|
}
|
|
|
|
// the return of the function must be an array
|
|
return users
|
|
}
|
|
|
|
// those data are for the interaction example
|
|
const openedRowId = ref<number>()
|
|
function onRowClick(row: any) {
|
|
if (openedRowId.value === row.id) {
|
|
openedRowId.value = undefined
|
|
}
|
|
else {
|
|
openedRowId.value = row.id
|
|
}
|
|
}
|
|
|
|
const incomingCallerId = ref<number>()
|
|
function onCallClick(row: any) {
|
|
if (incomingCallerId.value === row.id) {
|
|
incomingCallerId.value = undefined
|
|
}
|
|
else {
|
|
incomingCallerId.value = row.id
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<!--
|
|
We use v-model to let VFlexTableWrapper update queryParam
|
|
-->
|
|
<VFlexTableWrapper
|
|
v-model:page="queryParam.page"
|
|
v-model:limit="queryParam.limit"
|
|
v-model:searchTerm="queryParam.searchTerm"
|
|
v-model:sort="queryParam.sort"
|
|
:columns="columns"
|
|
:data="fetchData"
|
|
:total="total"
|
|
class="mt-4"
|
|
>
|
|
<!--
|
|
Here we retrieve the internal wrapperState.
|
|
Note that we can not destructure it
|
|
-->
|
|
<template #default="wrapperState">
|
|
<!--Table Pagination-->
|
|
<VFlexPagination
|
|
v-model:current-page="wrapperState.page"
|
|
:item-per-page="wrapperState.limit"
|
|
:total-items="wrapperState.total"
|
|
:max-links-displayed="2"
|
|
no-router
|
|
>
|
|
<!-- The controls can be updated anywhere in the slot -->
|
|
<template #before-pagination>
|
|
<VFlex class="mr-4">
|
|
<VField>
|
|
<VControl icon="lucide:search">
|
|
<input
|
|
v-model="wrapperState.searchInput"
|
|
type="text"
|
|
class="input is-rounded"
|
|
placeholder="Filter..."
|
|
>
|
|
</VControl>
|
|
</VField>
|
|
</VFlex>
|
|
</template>
|
|
|
|
<template #before-navigation>
|
|
<VFlex class="mr-4">
|
|
<VField>
|
|
<VControl>
|
|
<div class="select is-rounded">
|
|
<select v-model="wrapperState.limit">
|
|
<option :value="1">
|
|
1 results per page
|
|
</option>
|
|
<option :value="10">
|
|
10 results per page
|
|
</option>
|
|
<option :value="15">
|
|
15 results per page
|
|
</option>
|
|
<option :value="25">
|
|
25 results per page
|
|
</option>
|
|
<option :value="50">
|
|
50 results per page
|
|
</option>
|
|
</select>
|
|
</div>
|
|
</VControl>
|
|
</VField>
|
|
</VFlex>
|
|
</template>
|
|
</VFlexPagination>
|
|
|
|
<VFlexTable
|
|
rounded
|
|
clickable
|
|
@row-click="onRowClick"
|
|
>
|
|
<template #body>
|
|
<!--
|
|
The wrapperState.loading will be update
|
|
when the fetchData function is running
|
|
-->
|
|
<div v-if="wrapperState.loading" class="flex-list-inner">
|
|
<div
|
|
v-for="key in wrapperState.limit"
|
|
:key="key"
|
|
class="flex-table-item"
|
|
>
|
|
<VFlexTableCell :column="{ grow: true, media: true }">
|
|
<VPlaceloadAvatar size="medium" />
|
|
|
|
<VPlaceloadText
|
|
:lines="2"
|
|
width="60%"
|
|
last-line-width="20%"
|
|
class="mx-2"
|
|
/>
|
|
</VFlexTableCell>
|
|
<VFlexTableCell>
|
|
<VPlaceload width="60%" class="mx-1" />
|
|
</VFlexTableCell>
|
|
<VFlexTableCell>
|
|
<VPlaceload width="60%" class="mx-1" />
|
|
</VFlexTableCell>
|
|
<VFlexTableCell :column="{ align: 'end' }">
|
|
<VPlaceload width="45%" class="mx-1" />
|
|
</VFlexTableCell>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- This is the empty state -->
|
|
<div v-else-if="wrapperState.data.length === 0" class="flex-list-inner">
|
|
<VPlaceholderSection
|
|
title="No matches"
|
|
subtitle="There is no data that match your query."
|
|
class="my-6"
|
|
>
|
|
<template #image>
|
|
<img
|
|
class="light-image"
|
|
src="/images/illustrations/placeholders/search-4.svg"
|
|
alt=""
|
|
>
|
|
<img
|
|
class="dark-image"
|
|
src="/images/illustrations/placeholders/search-4-dark.svg"
|
|
alt=""
|
|
>
|
|
</template>
|
|
</VPlaceholderSection>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- We can inject content before any rows -->
|
|
<template #body-row-pre="{ row }">
|
|
<template v-if="row.id === incomingCallerId">
|
|
<VProgress size="tiny" class="m-0 mb-1" />
|
|
</template>
|
|
</template>
|
|
|
|
<!-- This is the body cell slot -->
|
|
<template #body-cell="{ row, column }">
|
|
<template v-if="column.key === 'name'">
|
|
<VAvatar
|
|
size="medium"
|
|
:picture="row.pic"
|
|
:badge="row.badge"
|
|
:initials="row.initials"
|
|
/>
|
|
<div>
|
|
<span class="dark-text">{{ row?.name }}</span>
|
|
<VTextEllipsis width="280px" class="light-text">
|
|
{{ row?.bio }}
|
|
</VTextEllipsis>
|
|
</div>
|
|
</template>
|
|
|
|
<template v-if="column.key === 'actions'">
|
|
<VAction>
|
|
{{ row?.id === openedRowId ? 'Hide details' : 'View details' }}
|
|
</VAction>
|
|
</template>
|
|
</template>
|
|
|
|
<!-- We can also inject content after rows -->
|
|
<template #body-row-post="{ row }">
|
|
<template v-if="row?.id === incomingCallerId">
|
|
<VTags class="mt-2 mb-0">
|
|
<VTag color="primary" outlined>
|
|
<VIcon class="is-inline mr-2" icon="lucide:send" />
|
|
Calling...
|
|
</VTag>
|
|
</VTags>
|
|
</template>
|
|
|
|
<template v-if="row?.id === openedRowId">
|
|
<div class="is-block p-4 my-2 is-rounded">
|
|
<div class="dark-text mb-4 is-size-4">
|
|
{{ row?.name }}'s details
|
|
</div>
|
|
<VFlex justify-content="space-between">
|
|
<VFlexItem>
|
|
<VCard>
|
|
<pre><code>{{ row }}</code></pre>
|
|
</VCard>
|
|
</VFlexItem>
|
|
|
|
<VFlexItem align-self="flex-end">
|
|
<VFlex flex-direction="column">
|
|
<VButton
|
|
v-if="row?.id === incomingCallerId"
|
|
class="mb-2"
|
|
color="danger"
|
|
@click="() => onCallClick(row)"
|
|
>
|
|
<VIcon class="is-inline mr-2" icon="lucide:phone-off" />
|
|
Cancel call
|
|
</VButton>
|
|
<VButton
|
|
color="primary"
|
|
outlined
|
|
:disabled="row.id === incomingCallerId"
|
|
:loading="row.id === incomingCallerId"
|
|
@click="() => onCallClick(row)"
|
|
>
|
|
<VIcon class="is-inline mr-2" icon="lucide:phone" />
|
|
Call {{ row?.name }}
|
|
</VButton>
|
|
</VFlex>
|
|
</VFlexItem>
|
|
</VFlex>
|
|
</div>
|
|
</template>
|
|
</template>
|
|
</VFlexTable>
|
|
|
|
<!--Table Pagination-->
|
|
<VFlexPagination
|
|
v-model:current-page="wrapperState.page"
|
|
class="mt-5"
|
|
:item-per-page="wrapperState.limit"
|
|
:total-items="wrapperState.total"
|
|
:max-links-displayed="2"
|
|
no-router
|
|
/>
|
|
</template>
|
|
</VFlexTableWrapper>
|
|
</template>
|
|
```
|
|
|
|
<!--/code-->
|