6
votes

I have a page with Vuetify Autocomplete component, and REST API backend with '/vendors' method. This method takes limit, page and name parameters and returns JSON with id and name fields.

I made some code with lazy list load on user input event. But now I want to add the ability to load this list on user scroll event.

For example, by default there is a list of 100 vendors. User scrolled this list until the end, then "some event" is called and loads next 100 of vendors. Then user keeps scrolling and the action is repeated.

Is it possible to made this with Vuetify Autocomplete component, or should i use another library?

Example code of current component is shown below:

<template>
  <v-autocomplete
          :items="vendors"
          v-model="selectedVendorId"
          item-text="name"
          item-value="id"
          label="Select a vendor"
          @input.native="getVendorsFromApi"
  ></v-autocomplete>
</template>

<script>
  export default {
    data () {
      return {
        page: 0,
        limit: 100,
        selectedVendorId: null,
        vendors: [],
        loading: true
      }
    },
    created: function (){
      this.getVendorsFromApi();
    },
    methods: {
      getVendorsFromApi (event) {
        return new Promise(() => {
          this.$axios.get(this.$backendLink 
                  + '/vendors?limit=' + this.limit 
                  + '&page=' + this.page 
                  + '&name=' + (event ? event.target.value : ''))
            .then(response => {
              this.vendors = response.data;
            })
        })
      }
    }
  }
</script>
2

2 Answers

3
votes

I was able to get auto-loading going with the Vuetify AutoComplete component. It's a bit of a hack, but basically the solution is to use the v-slot append item, the v-intersect directive to detect if that appended item is visible, and if it is, call your API to fetch more items and append it to your current list.

  <v-autocomplete
          :items="vendors"
          v-model="selectedVendorId"
          item-text="name"
          item-value="id"
          label="Select a vendor"
          @input.native="getVendorsFromApi"
  >
  <template v-slot:append-item>
    <div v-intersect="endIntersect" />
  </template>
</v-autocomplete>


...

export default {
  methods: {
    endIntersect(entries, observer, isIntersecting) {
      if (isIntersecting) {
        let moreVendors = loadMoreFromApi()
        this.vendors = [ ...this.vendors, ...moreVendors]
      }
    }
  }
}

In my use case, I was using API Platform as a backend, using GraphQL pagination using a cursor.

   <component
      v-bind:is="canAdd ? 'v-combobox' : 'v-autocomplete'"
      v-model="user"
      :items="computedUsers"
      :search-input.sync="search"
      item-text="item.node.userProfile.username"
      hide-details
      rounded
      solo
      :filter="
      (item, queryText, itemText) => { 
        return item.node.userProfile.username.toLocaleLowerCase().indexOf(queryText.toLocaleLowerCase()) > -1
        } "
      :loading="loading"
      item-value="username"
      class="text-left pl-1"
      color="blue-grey lighten-2"
      :label="label"
    >
      <template v-slot:selection="{ item }">
        <v-chip v-if="typeof item == 'object'">
          <v-avatar left>
            <v-img v-if="item.node.userProfile.image" :src="item.node.userProfile.image" />
            <v-icon v-else>mdi-account-circle</v-icon>
          </v-avatar>
          {{ item.node.userProfile.firstName }} {{ item.node.userProfile.lastName }}
        </v-chip>
        <v-chip v-else-if="typeof item == 'string'">
          {{ item }}
        </v-chip>
      </template>
      <template v-slot:item="{ item: { node } }">
        <v-list-item-avatar >
          <img v-if="node.userProfile.avatar" :src="node.userProfile.avatar" />
          <v-icon v-else>mdi-account-circle</v-icon>
        </v-list-item-avatar>
        <v-list-item-content class="text-left">
          <v-list-item-title>
            {{ $t('fullName', { firstName: node.userProfile.firstName, lastName: node.userProfile.lastName } )}}
          </v-list-item-title>
          <v-list-item-subtitle v-html="node.userProfile.username"></v-list-item-subtitle>
        </v-list-item-content>
      </template>
      <template v-slot:append-item="">
        <div v-intersect="endIntersect" >
        </div>
      </template>
    </component>
import { VCombobox, VAutocomplete } from "vuetify/lib";
import debounce from "@/helpers/debounce"
import { SEARCH_USER_BY_USERNAME } from "@/graphql/UserQueries";
const RESULTS_TO_SHOW = 5
export default {
  props: {
    canAdd: {
      type: Boolean,
      default: false,
    },
    value: [Object, String],
    label: String,
  },
  components: { VCombobox, VAutocomplete },
  apollo: {
    users: {
      query: SEARCH_USER_BY_USERNAME,
      variables() { 
        return  {
          username: this.search,
          numberToShow: RESULTS_TO_SHOW,
          cursor: null,
        }
      },
      watchLoading(isLoading) {
        this.loading = isLoading
      },
      skip() {
        if (this.search) {
          return !(this.search.length > 1)
        }
        return true
      },
    },
  },
  data() {
    return {
      user: this.value,
      search: "",
      cursor: null,
      loading: false,
    };
  },
  watch: {
    user(newValue) {
      let emit = newValue
      if (newValue) {
        emit = newValue.node
      }
      this.$emit("input", emit);
    },
    value(newValue) {
      if (this.user && this.user.node != newValue) {
        if (newValue == null) {
          this.user = null
        }
        else {
          this.user =  { node: newValue };
        }
      }
    },
    search(newValue) {
      this.debouncedSearch(newValue)
    },
  },
  methods: {
    endIntersect(entries, observer, isIntersecting) {
      if (isIntersecting && this.users && this.users.pageInfo.hasNextPage) {
        let cursor = this.users.pageInfo.endCursor
        
        this.$apollo.queries.users.fetchMore({
          variables: { cursor: cursor},
          updateQuery: (previousResult, { fetchMoreResult }) => {
            let edges = [
              ...previousResult.users.edges,
              ...fetchMoreResult.users.edges,
            ]

            let pageInfo = fetchMoreResult.users.pageInfo;
            return { 
              users: {
                edges: edges,
                pageInfo: pageInfo,
                __typename: previousResult.users.__typename,
              }
            }
          }
        })
      }
    },
    debouncedSearch: debounce(function (search) {
      if (this.users) {
        this.$apollo.queries.users.refetch({
          username: search,
          numberToShow: RESULTS_TO_SHOW, 
          cursor: null,
        });
      }
    }, 500),
    filter(item, queryText) {
      return item.node.userProfile.username.toLocaleLowerCase().indexOf(queryText.toLocaleLowerCase()) > -1
    }
  },
  computed: {
    computedUsers() {
      if (this.users){
        return this.users.edges
      }
      return []
    },
    skip() {
      if (this.search) {
        return this.search.length > 1
      }
      return false
    }
  }
};
</script>

Before scrolling down

After scrolling down

0
votes

Update June 12, 2021:

If you are using Vuetify 2.X, use Brettins' solution based on append-item slot and v-intersect directive.


Old answer:

Looks like it's not possible with default v-autocomplete component (at least in vuetify 1.5.16 or lower). The component that provides the most similar functionality is VueInfiniteAutocomplete.

But keep in mind that in this case there may be problems with styles, validation, etc.

There is an example with this library.

<template>
    <div>
    <vue-infinite-autocomplete
      :data-source="getAsyncOptions"
      :fetch-size="limit"
      v-on:select="handleOnSelect"
      :value="autocompleteViewValue"
    >
    </vue-infinite-autocomplete>
  </div>
</template>
<script>
  export default {
    data () {
      return {
          selectedVendorId : null,
          limit: 100,
          autocompleteViewValue: null
      }
    },
    methods: {
        getAsyncOptions(text, page, fetchSize) {
            return new Promise((resolve, reject) => {
                resolve(
                    this.$axios.get(this.$backendLink
                        + '/vendors?limit=' + fetchSize
                        + '&page=' + page
                        + '&name=' + text)
                        .then(response => {
                            //Response MUST contain 'id' and 'text' fields, and nothing else.
                            //If there are other fields, you should remove it here
                            //and create 'id' and 'text' fields in response JSON by yourself
                            return response.data;
                        })
                )
            });
        },

        handleOnSelect(selectedItem) {
            this.autocompleteViewValue = selectedItem.text;
            this.selectedVendorId = selectedItem.id;
        }
    }
  }
</script>

P.S.: If you just want to use v-autocomplete component with server-side pagination, you could create a "Load more..." button using append-item slot, as suggested in this issue.