How can I access modelValue from inside a direct in Vue 3 - vue.js

The question is: How to access el.modelValue and update it from custom directive?
directives: {
currency: {
mounted(el, binding, vnode, prevVnode) {
console.log(vnode, prevVnode)
el.oninput = (e) => {
if (Number.isNaN(e.data)) return
el.value = e.data
e.oninput = () => {
binding.value += 1
}
}
},
},

For reading, if you want to be sure that the value of the v-model is used instead of the value of the input (as they can differ) you could set the variable that is used for the v-model as directive value. As of mutating, you should dispatch the appropriate event to the element and let Vue handle the update of the v-model. Like in this example below:
StackBlitz
<template>
<input v-focus="value" v-model="value" type="text" />
</template>
<script setup lang="ts">
import { ref } from 'vue';
const value = ref('some string');
function patchInputValue(el, value) {
var event = new Event('input', { bubbles: true });
el.value = value;
el.dispatchEvent(event);
}
const vFocus = {
mounted: (el, binding) => {
// binding.value -> 'some string'
patchInputValue(el, 'new value');
},
};
</script>
Note that new Event() won't work in IE <11. There are a couple of polyfill packages that provide compatibility.

Related

How to implement debounce in vue3

I have a filter input field and want to filter a list of items. The list is large so I want to use debounce to delay the filter being applied until the user has stopped typing for improved user experience. This is my input field and it's bound to filterText that is used to filter the list.
<input type="text" v-model="state.filterText" />
I didn't find any nice solution as I wanted to see my binding in my template so I decided to share my solution. I wrote a simple debounce function and use the following syntax to bind the behavior:
setup() {
...
function createDebounce() {
let timeout = null;
return function (fnc, delayMs) {
clearTimeout(timeout);
timeout = setTimeout(() => {
fnc();
}, delayMs || 500);
};
}
return {
state,
debounce: createDebounce(),
};
},
And the template syntax:
<input
type="text"
:value="state.filterText"
#input="debounce(() => { state.filterText = $event.target.value })"
/>
Hi first time answering something here, so correct my answer as much as you want, I'd appreciate it.
I think that the prettiest and lightest solution is to create a directive globally that you can use as much as you want in all of your forms.
you first create the file with your directive, eg.
debouncer.js
and you create the function for the debouncing
//debouncer.js
/*
This is the typical debouncer function that receives
the "callback" and the time it will wait to emit the event
*/
function debouncer (fn, delay) {
var timeoutID = null
return function () {
clearTimeout(timeoutID)
var args = arguments
var that = this
timeoutID = setTimeout(function () {
fn.apply(that, args)
}, delay)
}
}
/*
this function receives the element where the directive
will be set in and also the value set in it
if the value has changed then it will rebind the event
it has a default timeout of 500 milliseconds
*/
module.exports = function debounce(el, binding) {
if(binding.value !== binding.oldValue) {
el.oninput = debouncer(function(){
el.dispatchEvent(new Event('change'))
}, parseInt(binding.value) || 500)
}
}
After you define this file you can go to your main.js import it and use the exported function.
//main.js
import { createApp } from 'vue'
import debounce from './directives/debounce' // file being imported
const app = createApp(App)
//defining the directive
app.directive('debounce', (el,binding) => debounce(el,binding))
app.mount('#app')
And its done, when you want to use the directive on an input you simply do it like this, no imports or anything.
//Component.vue
<input
:placeholder="filter by name"
v-model.lazy="filter.value" v-debounce="400"
/>
The v-model.lazy directive is important if you choose to do it this way, because by default it will update your binded property on the input event, but setting this will make it wait for a change event instead, which is the event we are emitting in our debounce function. Doing this will stop the v-model updating itself until you stop writing or the timeout runs out (which you can set in the value of the directive).
I hope this was understandable.
<template>
<input type="text" :value="name" #input="test" />
<span>{{ name }}</span>
</template>
<script lang="ts">
import { defineComponent, ref } from 'vue'
function debounce<T> (fn: T, wait: number) {
let timer: ReturnType<typeof setTimeout>
return (event: Event) => {
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
if (typeof fn === 'function') {
fn(event)
}
}, wait)
}
}
export default defineComponent({
setup () {
const name = ref('test')
function setInputValue (event: Event) {
const target = event.target as HTMLInputElement
name.value = target.value
}
const test = debounce(setInputValue, 1000)
return { name, test }
}
})
</script>
With Lodash, you have an easier solution:
<template>
<input type="text" :value="name" #input="onInput" />
<span>{{ name }}</span>
</template>
<script>
import debounce from "lodash/debounce"
export default {
setup () {
const onInput = debounce(() => {
console.log('debug')
}, 500)
return { onInput }
}
}
</script>
<input #input="updateValue"/>
const updateValue = (event) => {
const timeoutId = window.setTimeout(() => {}, 0);
for (let id = timeoutId; id >= 0; id -= 1) {
window.clearTimeout(id);
}
setTimeout(() => {
console.log(event.target.value)
}, 500);
};
You can try this one
Here's an example with Lodash and script setup syntax using a watcher to fire the debounced api call:
<script setup>
import { ref, watch } from 'vue'
import debounce from 'lodash.debounce'
const searchTerms = ref('')
const getFilteredResults = async () => {
try {
console.log('filter changed')
// make axios call here using searchTerms.value
} catch (err) {
throw new Error(`Problem filtering results: ${err}.`)
}
}
const debouncedFilter = debounce(getFilteredResults, 250) // 250ms delay
watch(() => searchTerms.value, debouncedFilter)
</script>
<template>
<input v-model="searchTerms" />
</template>
https://www.npmjs.com/package/vue-debounce now works for vue 3
It can be registered also with composition API like this
setup() {
...
},
directives: {
debounce: vue3Debounce({ lock: true })
}

How watch global variable in component vuejs?

I need global variables for errors. But I don't want set input variable for every component.
How I can watch $errors in component ABC without input variable?
(without <abc :errors="$errors"></abc>)
index.js:
Vue.prototype.$errors = {};
new Vue({
el: '#app',
render: h => h(App),
}
App.vue:
...
name: 'App',
components: {
ABC
}
...
methods:{
getContent() {
this.$errors = ...from axis...
}
Component ABC:
<template>
<div>{{ error }}</div>
</template>
...
watch: {
???
}
Here's an example of how it could be done:
const errors = Vue.observable({ errors: {} })
Object.defineProperty(Vue.prototype, '$errors', {
get () {
return errors.errors
},
set (value) {
errors.errors = value
}
})
new Vue({
el: '#app',
methods: {
newErrors () {
// Generate some random errors
const errors = {}
for (const property of ['name', 'type', 'id']) {
if (Math.random() < 0.5) {
errors[property] = 'Invalid value'
}
}
this.$errors = errors
}
}
})
new Vue({
el: '#app2',
watch: {
$errors () {
console.log('$errors has changed')
}
}
});
<script src="https://unpkg.com/vue#2.6.10/dist/vue.js"></script>
<div id="app">
<pre>{{ $errors }}</pre>
<button #click="newErrors">New errors</button>
</div>
<div id="app2">
<pre>{{ $errors }}</pre>
</div>
I've created two Vue instances to illustrate that the value really is shared. Clicking the button in the first instance will update the value of $errors and the watch is triggered in the second instance.
There are a few tricks in play here.
Firstly, reactivity can only track the reading and writing of properties of an observable object. So the first thing we do is create a suitable object:
const errors = Vue.observable({ errors: {} })
We then need to wire this up to Vue.prototype.$errors. By defining a get and set for that property we can proxy through to the underlying property within our observable object.
All of this is pretty close to how data properties work behind the scenes. For the data properties the observable object is called $data. Vue then uses defineProperty with get and set to proxy though from the Vue instance to the $data object, just like in my example.
as Estradiaz said:
You can use Vuex and access the value outside of Vue like in this answer: https://stackoverflow.com/a/47575742/10219239
This is an addition to Skirtles answer:
You can access such variables via Vue.prototype.variable.
You can set them directly, or use Vue.set, it works either way.
My code (basically the same as Skirtless):
main.js
const mobile = Vue.observable({ mobile: {} });
Object.defineProperty(Vue.prototype, '$mobile', {
get() { return mobile.mobile; },
set(value) { mobile.mobile = value; }
});
function widthChanged() {
if (window.innerWidth <= 768) {
if (!Vue.prototype.$mobile) Vue.set(Vue.prototype, '$mobile', true);
} else if (Vue.prototype.$mobile) Vue.set(Vue.prototype, '$mobile', false);
}
window.addEventListener("resize", widthChanged);
widthChanged();
Home.vue:
watch: {
'$mobile'(newValue) {
// react to Change in width
}
}

vuejs treeselect - delay loading does not work via vuex action

Using Vue TreeSelect Plugin to load a nested list of nodes from firebase backend. It's doc page says,
It's also possible to have root level options to be delayed loaded. If no options have been initially registered (options: null), vue-treeselect will attempt to load root options by calling loadOptions({ action, callback, instanceId }).
loadOptions (in my App.vue) dispatch vuex action_FolderNodesList, fetches (from firebase) formats (as required by vue-treeselect), and mutates the state folder_NodesList, then tries to update options this.options = this.get_FolderNodesList but this does not seems to work.
Here is the loadOptions method (in app.vue)
loadOptions() {
let getFolderListPromise = this.$store.dispatch("action_FolderNodesList");
getFolderListPromise.then(_ => {
this.options = this.get_FolderNodesList;
});
}
Vue errors out with Invalid prop: type check failed for prop "options". Expected Array, got String with value ""
I am not sure what am I doing wrong, why that does not work. A working Codesandbox demo
Source
App.vue
<template>
<div class="section">
<div class="columns">
<div class="column is-7">
<div class="field">
<Treeselect
:multiple="true"
:options="options"
:load-options="loadOptions"
:auto-load-root-options="false"
placeholder="Select your favourite(s)..."
v-model="value" />
<pre>{{ get_FolderNodesList }}</pre>
</div>
</div>
</div>
</div>
</template>
<script>
import { mapGetters } from "vuex";
import Treeselect from "#riophae/vue-treeselect";
import "#riophae/vue-treeselect/dist/vue-treeselect.css";
export default {
data() {
return {
value: null,
options: null,
called: false
};
},
components: {
Treeselect
},
computed: mapGetters(["get_FolderNodesList"]),
methods: {
loadOptions() {
let getFolderListPromise = this.$store.dispatch("action_FolderNodesList");
getFolderListPromise.then(_ => {
this.options = this.get_FolderNodesList;
});
}
}
};
</script>
Store.js
import Vue from "vue";
import Vuex from "vuex";
Vue.use(Vuex);
export const store = new Vuex.Store({
state: {
folder_NodesList: ""
},
getters: {
get_FolderNodesList(state) {
return state.folder_NodesList;
}
},
mutations: {
mutate_FolderNodesList(state, payload) {
state.folder_NodesList = payload;
}
},
actions: {
action_FolderNodesList({ commit }) {
fmRef.once("value", snap => {
var testObj = snap.val();
var result = Object.keys(testObj).reduce((acc, cur) => {
acc.push({
id: cur,
label: cur,
children: recurseList(testObj[cur])
});
return acc;
}, []);
commit("mutate_FolderNodesList", result);
});
}
}
});
Any help is appreciated.
Thanks
It seems you are calling this.options which would update the entire element while only the current expanding option should be updated.
It seems loadOptions() is called with some arguments that you can use to update only the current childnode. The first argument seems to contain all the required assets so I wrote my loadTreeOptions function like this:
loadTreeOptions(node) {
// On initial load, I set the 'children' to NULL for nodes to contain children
// but inserted an 'action' string with an URL to retrieve the children
axios.get(node.parentNode.action).then(response => {
// Update current node's children
node.parentNode.children = response.data.children;
// notify tree to update structure
node.callback();
}).catch(
errors => this.onFail(errors.response.data)
);
},
Then I set :load-options="loadTreeOptions" on the <vue-treeselect> element on the page. Maybe you were only missing the callback() call which updates the structure. My installation seems simpler than yours but it works properly now.

'this' context in functional component's child event handler

I am trying to create custom event handlers for child components/elements of the functional component. The problem is that when using a render() function to create the child components, I cannot access their this context.
Suppose we have the following functional component:
const Aggregate = {
functional: true,
props: {
value: Object // to work with v-model
},
render: function(createElement, context){
const template = []
const inputHandler = function(value, prop){
const data = Object.assign({}, context.props.value, { [prop]: value })
console.log(context.props.value)
console.log(data)
this.$emit('input', data)
}
for (const prop of Object.keys(context.props.value)){
const child = createElement('input', {
props: {
value: context.props[prop]
},
on: {
input: function(event){
// 'this' is not binded here - it is undefined,
// hence the inputHandler() function is
// rising an error
inputHandler.apply(this, [event.target.value, prop])
}
}
})
template.push(child)
}
return template
}
}
Is it possible to access this context for a vnode, when creating event handler this way?
P.S. Use case info: I want to implement a component that automatically generates <input> elements for a resource and uses two-way binding through v-model directive. I also want to use it in <table> where wrapping in <td> will be required, thus I made the component functional.
Functional components don't a have a "this", because there is no Vue instance for them. This makes them lightweight.
This also means emiting events from them is kind of harder, since you need to implement Vue's logic yourself.
Lacking an instance doesn't mean you cannot events, instead, you need to manually parse context.listeners and call the event handler manually. In the case of v-model, you need to call the input listener:
const Aggregate = {
functional: true,
props: {
value: Object // to work with v-model
},
render: function(createElement, context){
const template = []
const inputHandler = function(value, prop, handler){
const data = Object.assign({}, context.props.value, { [prop]: value })
console.log(context.props.value)
console.log(data)
// Call handler directly instead of using this.$emit
handler(data)
}
for (const prop of Object.keys(context.props.value)){
console.log(context.props.value, prop)
const child = createElement('input', {
// Small bug fixes in the following section:
domProps: {
value: context.props.value[prop]
},
// End bug fixes
on: {
input: function(event){
// pass `context.listeners.input` instead of binding here
inputHandler(event.target.value, prop, context.listeners.input)
}
}
})
template.push(child)
}
return template
}
}
new Vue({
el: "#app",
components: {
Aggregate
},
data: {
test: {
key1: "val1",
key2: "val2",
}
},
})
<!-- development version, includes helpful console warnings -->
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
<div id="app">
<aggregate v-model="test"></aggregate>
<pre>{{ test }}</pre>
<button #click="test = {...test, ping: 'pong'}">Add key</button>
</div>

How can I test a custom input Vue component

In the Vue.js documentation, there is an example of a custom input component. I'm trying to figure out how I can write a unit test for a component like that. Usage of the component would look like this
<currency-input v-model="price"></currency-input>
The full implementation can be found at https://v2.vuejs.org/v2/guide/components.html#Form-Input-Components-using-Custom-Events
The documentation says
So for a component to work with v-model, it should (these can be configured in 2.2.0+):
accept a value prop
emit an input event with the new value
How do I write a unit test that ensures that I've written this component such that it will work with v-model? Ideally, I don't want to specifically test for those two conditions, I want to test the behavior that when the value changes within the component, it also changes in the model.
You can do it:
Using Vue Test Utils, and
Mounting a parent element that uses <currency-input>
Fake an input event to the inner text field of <currency-input> with a value that it transforms (13.467 is transformed by <currency-input> to 13.46)
Verify if, in the parent, the price property (bound to v-model) has changed.
Example code (using Mocha):
import { mount } from '#vue/test-utils'
import CurrencyInput from '#/components/CurrencyInput.vue'
describe('CurrencyInput.vue', () => {
it("changing the element's value, updates the v-model", () => {
var parent = mount({
data: { price: null },
template: '<div> <currency-input v-model="price"></currency-input> </div>',
components: { 'currency-input': CurrencyInput }
})
var currencyInputInnerTextField = parent.find('input');
currencyInputInnerTextField.element.value = 13.467;
currencyInputInnerTextField.trigger('input');
expect(parent.vm.price).toBe(13.46);
});
});
In-browser runnable demo using Jasmine:
var CurrencyInput = Vue.component('currency-input', {
template: '\
<span>\
$\
<input\
ref="input"\
v-bind:value="value"\
v-on:input="updateValue($event.target.value)">\
</span>\
',
props: ['value'],
methods: {
// Instead of updating the value directly, this
// method is used to format and place constraints
// on the input's value
updateValue: function(value) {
var formattedValue = value
// Remove whitespace on either side
.trim()
// Shorten to 2 decimal places
.slice(0, value.indexOf('.') === -1 ? value.length : value.indexOf('.') + 3)
// If the value was not already normalized,
// manually override it to conform
if (formattedValue !== value) {
this.$refs.input.value = formattedValue
}
// Emit the number value through the input event
this.$emit('input', Number(formattedValue))
}
}
});
// specs code ///////////////////////////////////////////////////////////
var mount = vueTestUtils.mount;
describe('CurrencyInput', () => {
it("changing the element's value, updates the v-model", () => {
var parent = mount({
data() { return { price: null } },
template: '<div> <currency-input v-model="price"></currency-input> </div>',
components: { 'currency-input': CurrencyInput }
});
var currencyInputInnerTextField = parent.find('input');
currencyInputInnerTextField.element.value = 13.467;
currencyInputInnerTextField.trigger('input');
expect(parent.vm.price).toBe(13.46);
});
});
// load jasmine htmlReporter
(function() {
var env = jasmine.getEnv()
env.addReporter(new jasmine.HtmlReporter())
env.execute()
}())
<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/jasmine/1.3.1/jasmine.css">
<script src="https://cdn.jsdelivr.net/jasmine/1.3.1/jasmine.js"></script>
<script src="https://cdn.jsdelivr.net/jasmine/1.3.1/jasmine-html.js"></script>
<script src="https://npmcdn.com/vue#2.5.15/dist/vue.js"></script>
<script src="https://cdn.jsdelivr.net/npm/vue-template-compiler#2.5.15/browser.js"></script>
<script src="https://rawgit.com/vuejs/vue-test-utils/2b078c68293a41d68a0a98393f497d0b0031f41a/dist/vue-test-utils.iife.js"></script>
Note: The code above works fine (as you can see), but there can be improvements to tests involving v-model soon. Follow this issue for up-to-date info.
I would also mount a parent element that uses the component. Below a newer example with Jest and Vue Test Utils. Check the Vue documentation for more information.
import { mount } from "#vue/test-utils";
import Input from "Input.vue";
describe('Input.vue', () => {
test('changing the input element value updates the v-model', async () => {
const wrapper = mount({
data() {
return { name: '' };
},
template: '<Input v-model="name" />',
components: { Input },
});
const name = 'Brendan Eich';
await wrapper.find('input').setValue(name);
expect(wrapper.vm.$data.name).toBe(name);
});
test('changing the v-model updates the input element value', async () => {
const wrapper = mount({
data() {
return { name: '' };
},
template: '<Input v-model="name" />',
components: { Input },
});
const name = 'Bjarne Stroustrup';
await wrapper.setData({ name });
const inputElement = wrapper.find('input').element;
expect(inputElement.value).toBe(name);
});
});
Input.vue component:
<template>
<input :value="$attrs.value" #input="$emit('input', $event.target.value)" />
</template>