How to pass index in Vue3 v-for Array Refs - vue.js

In document, it gives code:
<div v-for="item in list" :ref="setItemRef"></div>
export default {
setup() {
let itemRefs = []
const setItemRef = el => {
if (el) {
itemRefs.push(el)
}
}
onBeforeUpdate(() => {
itemRefs = []
})
onUpdated(() => {
console.log(itemRefs)
})
return {
setItemRef
}
}
}
But I also want to pass the v-for index into handler, just like this:
// fake code
<div v-for="(item, index) in navItems" :key="index":ref="setNavItemRefs($el, index)">
<span>{{ item }}</span>
</div>
How can I bind this index?

Try out to define an inline handler like :
<div v-for="item in list" :ref="(el)=>setItemRef(el,index)"></div>
and
const setItemRef = (el,index) => {
if (el) {
itemRefs.push(el)
}
}

Related

Using vitest and testing-library is there a way to segregate component renders on a test by test basis?

I have a simple list component written in Vue3 that I am using to learn how to write automated test with Vitest and testing-library. However every test method seems to be rendered together, causing my getByText calls to throw the error TestingLibraryElementError: Found multiple elements with the text: foo.
This is the test I have written:
import { describe, it, expect, test } from 'vitest'
import { render, screen, fireEvent } from '#testing-library/vue'
import TmpList from '../ui/TmpList.vue'
const listItems = ['foo', 'bar']
describe('TmpList', () => {
// Test item-content slot rendering
test('renders item-content slot', () => {
const slotTemplate = `
<template v-slot:item-content="{ item }">
<div> {{ item }} </div>
</template>`;
render(TmpList, { props: { listItems }, slots: { 'item-content': slotTemplate } });
listItems.forEach(li => {
expect(screen.getByText(li)).toBeTruthy();
})
})
// Test list item interaction
test('should select item when clicked and is selectable', async () => {
const slotTemplate = `
<template v-slot:item-content="{ item }">
<div> {{ item }} </div>
</template>`;
render(TmpList, { props: { listItems, selectable: true }, slots: { 'item-content': slotTemplate } });
const firstItem = screen.getByText(listItems[0]);
await fireEvent.click(firstItem);
expect(firstItem.classList).toContain('selected-item')
})
})
The component:
<template>
<ul>
<li v-for="(item, index) in listItems" :key="`list-item-${index}`" #click="onItemClick(index)"
class="rounded mx-2" :class="{
'selected-item bg-secondary-600/20 text-secondary':
selectedIndex == index,
'hover:bg-zinc-200/30': selectable,
}">
<slot name="item-content" :item="item"></slot>
</li>
</ul>
</template>
<script setup lang="ts">
import { computed, ref } from "vue";
export interface Props {
listItems: any[];
selectable?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
selectable: false,
});
const selectedIndex = ref<number>(-1);
const onItemClick = (index: number) => {
if (props.selectable) {
selectedIndex.value = index;
}
};
</script>
This is the full error I get in the terminal:
TestingLibraryElementError: Found multiple elements with the text: foo
Here are the matching elements:
Ignored nodes: comments, script, style
<div>
foo
</div>
Ignored nodes: comments, script, style
<div>
foo
</div>
(If this is intentional, then use the `*AllBy*` variant of the query (like `queryAllByText`, `getAllByText`, or `findAllByText`)).
Ignored nodes: comments, script, style
<body>
<div>
<ul
data-v-96593be0=""
>
<li
class="rounded mx-2"
data-v-96593be0=""
>
<div>
foo
</div>
</li>
<li
class="rounded mx-2"
data-v-96593be0=""
>
<div>
bar
</div>
</li>
</ul>
</div>
<div>
<ul
data-v-96593be0=""
>
<li
class="rounded mx-2 hover:bg-zinc-200/30"
data-v-96593be0=""
>
<div>
foo
</div>
</li>
<li
class="rounded mx-2 hover:bg-zinc-200/30"
data-v-96593be0=""
>
<div>
bar
</div>
</li>
</ul>
</div>
</body>
❯ Object.getElementError node_modules/#testing-library/dom/dist/config.js:37:19
❯ getElementError node_modules/#testing-library/dom/dist/query-helpers.js:20:35
❯ getMultipleElementsFoundError node_modules/#testing-library/dom/dist/query-helpers.js:23:10
❯ node_modules/#testing-library/dom/dist/query-helpers.js:55:13
❯ node_modules/#testing-library/dom/dist/query-helpers.js:95:19
❯ src/components/__tests__/SUList.spec.ts:54:33
52|
53| render(TmpList, { props: { listItems, selectable: true }, slots: { 'item-content': slotTemplate } });
54| const firstItem = screen.getByText(listItems[0]);
| ^
55| await fireEvent.click(firstItem);
56| expect(firstItem.classList).toContain('selected-item')
I know I could use the getAllByText method to query multiple items, but in this test I am expecting only one element to be found. The duplication is related to the rendering in the test, not an issue with the actual component.
Am I doing something wrong when writing the tests? Is there a way to ensure that each render will be executend independetly of renders from other tests?
Every render() returns #testing-library's methods (query* /get* /find* ) scoped to the template being rendered.
In other words, they normally require a container parameter, but when returned by render, the container is already set to that particular render's DOM:
it('should select on click', async () => {
const { getByText } = render(TmpList, {
props: { listItems, selectable: true },
slots: { 'item-content': slotTemplate },
})
const firstItem = getByText(listItems[0])
expect(firstItem).not.toHaveClass('selected-item')
await fireEvent.click(firstItem)
expect(firstItem).toHaveClass('selected-item')
})
Notes:
fireEvent is no longer returning a promise in latest versions of #testing-library. If, in the version you're using, still returns a promise, keep the async - only true for #testing-library/react.
you want to get to a point where you no longer need to import screen in your test suite
If you find yourself writing the same selector or the same render parameters multiple times, it might make sense to write a renderComponent helper at the top of your test suite:
describe(`<ListItems />`, () => {
// define TmpList, listItems, slotTemplate
const defaults = {
props: { listItems, selectable: true },
slots: { 'item-content': slotTemplate },
}
const renderComponent = (overrides = {}) => {
// rendered test layout
const rtl = render(TmpList, {
...defaults,
...overrides
})
return {
...rtl,
getFirstItem: () => rtl.getByText(listItems[0]),
}
}
it('should select on click', async () => {
const { getFirstItem } = renderComponent()
expect(getFirstItem()).not.toHaveClass('selected-item')
await fireEvent.click(getFirstItem())
expect(getFirstItem()).toHaveClass('selected-item')
})
it('does something else with different props', () => {
const { getFirstItem } = renderComponent({
props: /* override defaults.props */
})
// expect(getFirstItem()).toBeOhSoSpecial('sigh...')
})
})
Note I'm spreading rtl in the returned value of renderComponent(), so all the get*/find*/query* methods are still available, for the one-off usage, not worth writing a getter for.

How to use Axios with Vue 3 Composition API

I am attempting to build a Pokemon filtered search app with Vue 3 and Composition API based on the following tutorial: https://www.youtube.com/watch?v=QJhqr7jqxVo. (GitHub: https://github.com/ErikCH/PokemonVue)
The fetch method used in the search component includes a reduce() function to handle urlIdLookup based on a specific id assigned to each Pokemon in the API response:
const state = reactive({
pokemons: [],
filteredPokemon: computed(()=> updatePokemon()),
text: "",
urlIdLookup: {}
});
fetch("https://pokeapi.co/api/v2/pokemon?offset=0")
.then((res) => res.json())
.then((data) => {
console.log(data);
state.pokemons = data.results;
state.urlIdLookup = data.results.reduce((acc, cur, idx)=>
acc = {...acc, [cur.name]:idx+1 }
,{})
console.log('url',state.urlIdLookup+1)
});
urlIdLookup is then passed into the route used to display selected Pokemon info:
<div
class="ml-4 text-2xl text-blue-400"
v-for="(pokemon, idx) in filteredPokemon"
:key="idx"
>
<router-link :to="`/about/${urlIdLookup[pokemon.name]}`">
{{ pokemon.name }}
</router-link>
</div>
Instead of using the above fetch setup, I wish to use Axios to handle the request and response from the Pokemon API. After installing Axios in the project and importing it into the component, I added a new fetchPokemon method:
const fetchPokemon = () => {
axios.get('https://pokeapi.co/api/v2/pokemon?offset=0')
.then(response => {
state.pokemons = response.data
})
}
onMounted(() => {
fetchPokemon()
})
While using Axios in this new fetch method, I want to handle urlIdLookup similar to the previous fetch setup, but without using the reduce() method and de-structured accumulator, if possible. How can I go about using Axios to retrieve the urlId of each Pokemon, then pass that urlId into the "about" route in the template?
Here is the full component:
<template>
<div class="w-full flex justify-center">
<input placeholder="Enter Pokemon here" type="text"
class="mt-10 p-2 border-blue-500 border-2" v-model="text" />
</div>
<div class="mt-10 p-4 flex flex-wrap justify-center">
<div
class="ml-4 text-2xl text-blue-400"
v-for="(pokemon, idx) in filteredPokemon"
:key="idx"
>
<router-link :to="`/about/${urlIdLookup[pokemon.name]}`">
{{ pokemon.name }}
</router-link>
</div>
</div>
</template>
<script>
import axios from 'axios';
import { reactive, toRefs, computed, onMounted } from "vue";
export default {
setup() {
const state = reactive({
pokemons: [],
filteredPokemon: computed(()=> updatePokemon()),
text: "",
urlIdLookup: {}
});
const fetchPokemon = () => {
axios.get('https://pokeapi.co/api/v2/pokemon?offset=0')
.then(response => {
state.pokemons = response.data
})
}
onMounted(() => {
fetchPokemon()
})
// fetch("https://pokeapi.co/api/v2/pokemon?offset=0")
// .then((res) => res.json())
// .then((data) => {
// console.log(data);
// state.pokemons = data.results;
// state.urlIdLookup = data.results.reduce((acc, cur, idx)=>
// acc = {...acc, [cur.name]:idx+1 }
// ,{})
// console.log('url',state.urlIdLookup+1)
// });
function updatePokemon(){
if(!state.text){
return []
}
return state.pokemons.filter((pokemon)=>
pokemon.name.includes(state.text)
)
}
return { ...toRefs(state), fetchPokemon, updatePokemon };
}
};
</script>
If I understood you correctly take a look at following snippet:
const { reactive, toRefs, computed, onMounted } = Vue
const { axioss } = axios
const app = Vue.createApp({
setup() {
const state = reactive({
pokemons: [],
filteredPokemon: computed(() => updatePokemon()),
text: "",
urlIdLookup: {},
});
const fetchPokemon = () => {
axios
.get("https://pokeapi.co/api/v2/pokemon?offset=0")
.then((response) => {
state.pokemons = response.data.results; // 👈 get just results
});
};
fetchPokemon();
// 👇 function to get index
const getPokemonId = (item) => {
return state.pokemons.findIndex((p) => p.name === item);
};
function updatePokemon() {
if (!state.text) {
return [];
}
return state.pokemons.filter((pokemon) =>
pokemon.name.includes(state.text)
);
}
// 👇 return new function
return { ...toRefs(state), fetchPokemon, updatePokemon, getPokemonId };
},
})
app.mount('#demo')
<script src="https://unpkg.com/vue#3.2.29/dist/vue.global.prod.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/axios/0.26.1/axios.min.js" integrity="sha512-bPh3uwgU5qEMipS/VOmRqynnMXGGSRv+72H/N260MQeXZIK4PG48401Bsby9Nq5P5fz7hy5UGNmC/W1Z51h2GQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<div id="demo">
<div class="w-full flex justify-center">
<input
placeholder="Enter Pokemon here"
type="text"
class="mt-10 p-2 border-blue-500 border-2"
v-model="text"
/>
</div>
<div class="mt-10 p-4 flex flex-wrap justify-center">
<div
class="ml-4 text-2xl text-blue-400"
v-for="(pokemon, i) in filteredPokemon"
:key="i"
>
<!-- // 👇 call function to get index -->
<router-link :to="`/about/${getPokemonId(pokemon.name)}`">
{{ pokemon.name }} - id {{ getPokemonId(pokemon.name) }}
</router-link>
</div>
</div>
</div>
it seem id is not necessary, why not use name be id. if you want use interger
be must, you can foreach results set index be id to each item, then
<router-link :to="`/about/${pokemon.id}`">
{{ pokemon.name }}
</router-link>

vue js filtering with search bar

I have created an app that requests from an API the data and creates flexboxes. Now I added a search box and I would like to filter the articles by their contact and/or title.
I've also created a computed property to filter the returned list of items but when I replace in line 11 the paginated('items') with paginated('filteredArticles') that returns nothing.
What did I do wrong?
<template>
<div id="app">
<div class="search-wrapper">
<input type="text"
class="search-bar"
v-model="search"
placeholder="Search in the articles"/>
</div>
<paginate ref="paginator" class="flex-container" name="items" :list="items">
<li v-for="(item, index) in paginated('items')" :key="index" class="flex-item">
<div id="image"><img :src="item.image && item.image.file" /></div>
<div id="date">{{ item.pub_date }}</div>
<div id="title"> {{ item.title }}</div>
<div class="article">{{item.details_en}}</div>
</li>
</paginate>
<paginate-links for="items" :limit="2" :show-step-links="true"></paginate-links>
</div>
</template>
<script>
import axios from "axios";
export default {
data() {
return {
items: [],
paginate: ["items"],
search:'',
};
},
created() {
this.loadPressRelease();
},
methods: {
loadPressRelease() {
axios
.get(`https://zbeta2.mykuwaitnet.net/backend/en/api/v2/media-center/press-release/?page_size=61&type=5`)
.then((response) => {
this.items = response.data.results;
});
},
},
computed:{
filteredArticles() {
return this.items.filter(item=>item.includes(this.search))
}
}
};
</script>
You need fields you want to search and connvert search string and fields with toLowerCase() or toUpperCase():
computed : {
filteredArticles() {
if (!this.search) return this.items
return this.items.filter(item => {
return (item.title.toLowerCase().includes(this.search.toLowerCase()) || item.contact.toLowerCase().includes(this.search.toLowerCase()));
})
}
}
Your computed doesn't seem correct. Since items is an array of objects, you'd need to do this:
filteredArticles() {
if (!this.search) {
return this.items;
}
return this.items.filter(item => {
return item.title.includes(this.search);
})
}
Note that this will only search the title field, and it's case sensitive.

input field value keeps getting reset #input?

I have created an custom reusable autocomplete component.The issue i am facing is whenever i start to type anything into the fields(#input) the value in the input field gets reset. I feel it has something to do within the code written in the debounce function but i am not sure.Plz help?
main.js
Vue.component('AutoComplete', {
props: ['list','value','title'],
data() {
return {
input: '',
}
},
template: `<template>
<div class="autocomplete">
<input style="font-size: 12pt; height: 36px; width:1800px; " type="text" v-model="input" #input="handleInput"/>
<ul v-if="input" >
<li v-for="(item, i) in list" :key="i" #click="setInput(item)" >
<template v-if="title!='manager'">
<div class="container">
<p>
<b>ID:</b>
{{item.id}}
</p>
<p>
<b>Description:</b>
{{item.description}}
</p>
</div>
</template>
<template v-else>
<div class="container">
<p>
<b>ID:</b>
{{item.id}}
</p>
<p>
<b>First Name:</b>
{{item.firstName}}
</p>
<p>
<b>Last Name:</b>
{{item.lastName}}
</p>
</div>
</template>
</li>
</ul>
</div>
</template>`,
methods: {
handleInput(e) {
console.log('inside handleInput')
this.$emit('input', e.target.value)
},
setInput(value) {
console.log('inside setInput')
this.input = value
this.$emit('click', value)
}
},
watch: {
$props: {
immediate: true,
deep: true,
handler(newValue, oldValue) {
console.log('new value is'+newValue)
console.log('old value is'+oldValue)
console.log('value inside handler'+this.value)
console.log('list inside handler'+this.list)
console.log('title inside handler'+this.title)
this.input=this.value
}
}
}
})
Currently i have called this component from JobDetail.vue page like this-
JobDetail.vue
<template>
<b-field label="Custom Action">
<AutoComplete v-on:input="getAsyncDataAction" v-on:click="(option) => {updateRowValue('records', props.index, 'action', option.id); props.row.action = option.id}" :value="props.row.action" :list="dataAction" title='action' >
</AutoComplete>
</b-field>
</template>
<script>
import { viewMixin } from "../viewMixin.js";
import debounce from "lodash/debounce";
import api from "../store/api";
const ViewName = "JobDetail";
export default {
name: "JobDetail",
mixins: [viewMixin(ViewName)],
data() {
return {
dataAction: [],
isFetching: false
};
},
methods: {
getAsyncDataAction: debounce(function(name) {
if (!name.length) {
this.dataAction = [];
return;
}
this.isFetching = true;
api
.getSearchData(`/action/?query=${name}`)
.then(response => {
this.dataAction = [];
response.forEach(item => {
this.dataAction.push(item);
});
})
.catch(error => {
this.dataAction = [];
throw error;
})
.finally(() => {
this.isFetching = false;
});
}, 500)
}
};
</script>
viewmixin.js
computed: {
viewData() {
return this.$store.getters.getViewData(viewName)
},
objectData() {
return this.$store.getters.getApiData(this.viewData.api_id).data
},
sessionData() {
return this.$store.getters.getSessionData()
},
isLoading() {
return this.$store.getters.getApiData(this.viewData.api_id).isLoading
},
newRecord() {
return this.$route.params.id === null;
}
},
I don't understand why the input fields value keeps resetting #input. Please help and also let me know if this is the correct approach?

How can I call a statement or method after the loop complete on vue component?

My vue component like this :
<template>
<div class="row">
<div class="col-md-3" v-for="item in items">
...
</div>
</div>
</template>
<script>
export default {
...
computed: {
items() {
...
}
},
...
}
</script>
If the loop complete, I want to call a statement or method
So the statement is executed when the loop completes
How can I do it?
Update :
From Kira San answer, I try like this :
<template>
<div class="row">
<div class="col-md-3" v-for="(item, key) in items" v-for-callback="{key: key, array: items, callback: callback}">
...
</div>
</div>
</template>
<script>
export default {
...
computed: {
items() {
const n = ...
return n
}
},
directives: {
forCallback(el, binding) {
let element = binding.value
if (element.key == element.array.length - 1)
if (typeof element.callback === 'function') {
element.callback()
}
}
},
methods: {
callback() {
console.log('v-for loop finished')
}
}
}
</script>
The console log not display
My items is object
If do console.log(n) in items, the result like this :
Look at this example.
new Vue({
el: '#app',
computed: {
items() {
return {item1: 'value1', item2: 'value2'}
}
},
methods: {
callback() {
console.log('v-for loop finished')
}
},
directives: {
forCallback(el, binding) {
let element = binding.value
var key = element.key
var len = 0
if (Array.isArray(element.array)) {
len = element.array.length
}
else if (typeof element.array === 'object') {
var keys = Object.keys(element.array)
key = keys.indexOf(key)
len = keys.length
}
if (key == len - 1) {
if (typeof element.callback === 'function') {
element.callback()
}
}
}
},
})
<script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.3.4/vue.min.js"></script>
<div id="app">
<div class="row">
<div class="col-md-3" v-for="(item, key) in items" v-for-callback="{key: key, array: items, callback: callback}">
...
</div>
</div>
</div>
You can accomplish that with a directive.
Example usage:
<div class="col-md-3" v-for="(item, key) in items" v-for-callback="{key: key, array: items, callback: callback}">
<!--content-->
</div>
Create a new directive for-callback which will keep track of the elements that was rendered with v-for. It will basically check if the current key is the end of the array. If so, it will execute a callback function that you provide.
Vue.directive('for-callback', function(el, binding) {
let element = binding.value
if (element.key == element.array.length - 1)
if (typeof element.callback === 'function') {
element.callback()
}
})
Or if you don't want to define it globally. Add this to your component options instead:
directives: {
forCallback(el, binding) {
let element = binding.value
if (element.key == element.array.length - 1)
if (typeof element.callback === 'function') {
element.callback()
}
}
}
v-for-callback expects an options object.
{
key: key, // this will contain the item key that was generated with `v-for`
array: items, // the actual `v-for` array
callback: callback // your callback function (in this example, it's defined in the component methods
}
Then in your component options:
methods: {
callback() {
console.log('v-for loop finished')
}
}
You can do with an if inside the loop, like this:
<template>
<div class="row">
<div class="col-md-3" v-for="(item, index in items)">
<div v-if="index === items.length -1">
Display what ever you want here
<\div>
</div>
</div>
</template>
Or if you want to do something in js then call a function when the same condition is true, like this:
<div class="col-md-3" v-for="(item, index in items)">
<div v-if="index === items.length -1 && lastIteration()">
Display what ever you want here
<\div>
</div>
methods: {
lastIteration() {
alert("last");
}
}
I didn't test it but the ideea is good and it should work. Hope to answer your question :)