Last active
July 24, 2018 06:00
-
-
Save 4lg4/a3d064a06f50a936f4d920c5d4cd43c3 to your computer and use it in GitHub Desktop.
Autocomplete input with debounce
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<template> | |
<div class="AppAutocomplete"> | |
<input v-model="theValue" @input="change" :placeholder="placeholder" @focus="focus" @blur="focus('blur')" ref="input" autocomplete="nope" /> | |
<div class="clear" @click="clearAll" v-if="focused"> | |
<img src="/static/img/clear.png" /> | |
</div> | |
<AppLoaderBar v-if="loading"/> | |
<div class="suggestions"> | |
<ul> | |
<li v-for="(item, index) in theList" :key="index" @click="selected(item)"> | |
<div>{{ item.formattedAddress }}</div> | |
</li> | |
</ul> | |
</div> | |
</div> | |
</template> | |
<script> | |
import AppLoaderBar from '@/components/AppLoaderBar'; | |
import AppButton from '@/components/AppButton'; | |
const debounce = function debounce(func) { | |
const wait = 300; | |
let timeout; | |
return function(...args) { | |
// eslint-disable-next-line no-invalid-this | |
const ctx = this; | |
clearTimeout(timeout); | |
timeout = setTimeout(() => func.apply(ctx, args), wait); | |
}; | |
}; | |
export default { | |
name: 'AppAutocomplete', | |
components: {AppLoaderBar, AppButton}, | |
props: { | |
placeholder: { | |
type: String, | |
default: '', | |
}, | |
value: { | |
type: [String, Object], | |
default: '', | |
}, | |
delay: { | |
type: Number, | |
default: 0, | |
}, | |
list: { | |
type: Array, | |
default: undefined, | |
}, | |
}, | |
data() { | |
return { | |
theValue: this.value, | |
theList: this.list, | |
selectedItem: undefined, | |
loading: false, | |
focused: false, | |
searched: false, | |
requestInProgress: false, | |
runRequestAgain: false, | |
}; | |
}, | |
methods: { | |
focusClick() { | |
this.$refs.input.focus(); | |
this.focus(); | |
}, | |
focus(blur) { | |
this.focused = true; | |
this.$emit('focus', true); | |
}, | |
change(evt) { | |
this.selectedItem = undefined; | |
if (this.theValue.length === 0) { | |
return this.clearList(); | |
} | |
if (this.theValue.length < 4) { | |
return true; | |
} | |
this.$emit('error', ''); // clear the error | |
this.loading = true; | |
return this.changeDebounced(); | |
}, | |
/* eslint-disable no-invalid-this */ | |
changeDebounced: debounce(function() { | |
this.$emit('change'); | |
this.getList(); | |
return true; | |
}), | |
/* eslint-enable no-invalid-this */ | |
getList: async function() { | |
if (this.requestInProgress) { | |
this.runRequestAgain = true; | |
return false; | |
} | |
this.requestInProgress = true; | |
if (this.theValue.length === 0) { | |
this.loading = false; | |
return this.clearList(); | |
} | |
try { | |
this.theList = await this.$http | |
.get('suggestions', { | |
params: { | |
signed_request: this.context.signed_request, | |
query: this.theValue, | |
}, | |
}) | |
.then(({data})=> data.suggestions); | |
} catch (err) { | |
console.error('ERROR', err); | |
const response = err.response || {}; | |
let errMsg = `backend`; | |
if (response.status === 404) { | |
this.clearList(); | |
} | |
if (response.status !== 404 && typeof response !== 'string') { | |
this.$emit('error', errMsg); | |
} | |
} | |
this.loading = false; | |
this.searched = true; | |
this.requestInProgress = false; | |
if (this.runRequestAgain) { | |
this.runRequestAgain = false; | |
this.getList(); | |
} | |
}, | |
selected(item) { | |
this.selectedItem = item; | |
this.clearList(); | |
this.theValue = item.formattedAddress; | |
this.$emit('focus', false); | |
}, | |
selectedEmit() { | |
this.$emit('input', this.selectedItem); | |
}, | |
clearAll() { | |
this.theValue = ''; | |
this.clearList(); | |
}, | |
clearList() { | |
this.searched = false; | |
this.theList = []; | |
}, | |
}, | |
updated() { | |
// console.log('Autocomplete updated', this.theList); | |
}, | |
}; | |
</script> | |
<style scoped> | |
.AppAutocomplete { | |
width: 100%; | |
/* height: 100px; */ | |
position: relative; | |
} | |
.AppAutocomplete input { | |
-webkit-appearance: none; | |
border-radius: 0; | |
width: 100%; | |
/* font-size: 30px; */ | |
border: 1px solid #e7e7e7; | |
/* border-radius: 5px; */ | |
height: 60px; | |
padding: 0 10px; | |
/* font-weight: 200; */ | |
color: #475560; | |
} | |
.AppAutocomplete input::placeholder { | |
color: rgba(58, 73, 89, 0.74); | |
text-align: center; | |
/* font-weight: 100; */ | |
/* font-size: 20px; */ | |
} | |
.suggestions { | |
width: 100%; | |
/* height: 100%; */ | |
overflow: auto; | |
padding: 0 0 200px 0; | |
} | |
ul { | |
width: 100%; | |
margin: 0; | |
padding: 0; | |
} | |
li { | |
width: 100%; | |
margin: 0; | |
padding: 15px 10px; | |
background: #ffffff; | |
cursor: pointer; | |
list-style-type: none; | |
/* border-radius: 5px; */ | |
border: 1px solid #e5e5e5; | |
border-top: 0; | |
height: 52px; | |
} | |
li div { | |
width: 100%; | |
white-space: nowrap; | |
overflow: hidden; | |
color: #475560; | |
font-size: 14px; | |
} | |
li:hover { | |
background: lightgrey; | |
} | |
.clear { | |
position: absolute; | |
top: 1px; | |
right: 1px; | |
height: 58px; | |
width: 40px; | |
text-align: center; | |
padding: 15px 0; | |
cursor: pointer; | |
} | |
</style> | |
<TEST> | |
<script> | |
import Component from '@/components/AppAutocomplete'; | |
import {createLocalVue, mount} from '@vue/test-utils'; | |
import MyStore from '@/core/MyStore'; | |
const localVue = createLocalVue(); | |
localVue.use(MyStore); | |
const propsData = { | |
value: 'Alga.me', | |
delay: 500, | |
}; | |
describe('AppAutocomplete.vue', () => { | |
const wrapper = mount(Component, { | |
localVue, | |
propsData, | |
}); | |
const component = wrapper.vm; | |
const input = ()=> wrapper.find('input'); | |
it('should render correct contents', () => { | |
expect(component.$el.classList.contains('AppAutocomplete')).toEqual(true); | |
expect(input()).toBeDefined(); | |
expect(wrapper.find('.suggestions')).toBeDefined(); | |
expect(input().element.value).toEqual(propsData.value); | |
}); | |
it(`should change data theValue as input content change`, ()=> { | |
const newValue = 'Alga.me/newValue'; | |
input().element.value = newValue; | |
input().trigger('input'); | |
expect(component.theValue).toEqual(newValue); | |
}); | |
it(`should wait ${propsData.delay} miliseconds before execute`, async ()=> { | |
const newValue = 'Alga.me/newValue/withDelay'; | |
input().element.value = newValue; | |
input().trigger('input'); | |
const result = await new Promise((resolve, reject)=> { | |
component.change(); | |
setTimeout(()=> resolve(wrapper.emitted('change')), propsData.delay + 500); // add more 500 milliseconds to be safe to get some result from the input | |
}); | |
expect(result).toBeDefined(); | |
}); | |
it(`should receive an Array of objects [{id, formattedAddress}] from the enpoint`, async ()=> { | |
const newValue = 'wollstonecraft'; | |
input().element.value = newValue; | |
input().trigger('input'); | |
expect(component.theValue).toEqual(newValue); | |
await component.getList(); | |
expect(component.theList).toBeDefined(); | |
expect(component.theList instanceof Array).toEqual(true); | |
expect(component.theList[0]).toHaveProperty('id'); | |
expect(component.theList[0]).toHaveProperty('formattedAddress'); | |
}); | |
it(`should render a list of suggestions from the returned list`, ()=> | |
expect(wrapper.find('.suggestions ul').element.childNodes.length).toBeGreaterThan(0) | |
); | |
it(`should fulfill the autocomplete input with the selection and emit an event with the result object`, ()=> { | |
wrapper.find('.suggestions ul li').trigger('click'); | |
const emitted = wrapper.emitted('input')[0][0]; | |
expect(emitted).toBeDefined(); | |
expect(emitted).toHaveProperty('id'); | |
expect(emitted).toHaveProperty('formattedAddress'); | |
}); | |
}); | |
</script> | |
</TEST> | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment