Files
oa/documentation/flex-table/v-flex-table-wrapper-advanced-documentation.md
2025-05-24 01:49:48 +09:00

14 KiB

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.

<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>