How to DRY up VueJS + Vuelidate server side validation error code? - javascript

I'm working on a VueJS project (using the Quasar framework). When building a form component, for example to edit user settings, I have frontend validation, to check required fields etc. using vuelidate, and I want to also show backend validation errors. Currently my setup (for a sample form) is as follows:
script:
export default {
name: 'UserEditForm',
mixins: [formServerValidation],
data: function () {
return {
form: {
...
name: this.$store.state.currentUser.attributes.name,
currentPassword: 'winnerwinner',
serverErrors: {
// Todo: Autogenerate all server errors
}
}
}
},
methods: {
onSubmit () {
const name = this.form.name
const currentPassword = this.form.currentPassword
this.clearServerErrors()
this.$store.dispatch('updateUser', { name, currentPassword, }).then((result) => {
console.log("Server update success")
}).catch(err => {
console.log("Server update error")
const mappedErrors = this.mapServerErrors(err.response.data.errors)
merge(this.form.serverErrors, mappedErrors)
this.$refs.form.validate()
})
},
serverError: function (fieldName) {
return (value, vm) => {
return !(
Object.prototype.hasOwnProperty.call(vm, 'serverErrors') &&
Object.prototype.hasOwnProperty.call(vm.serverErrors, fieldName)
)
}
}
},
validation: {
form: {
...
name: {
required,
serverError: this.serverError('name')
},
currentPassword: {
required,
serverError: this.serverError('currentPassword')
},
...
}
}
}
template:
<template>
<q-form #submit="onSubmit" ref="form">
...
<q-input
field-name="name"
type="text"
v-model="form.name"
label="Name"
lazy-rules
#input="clearServerError('name', $event)"
:rules="[
val => $v.form.name.required || 'Enter name',
val => $v.form.name.serverError || form.serverErrors.name,
]" />
<q-input
field-name="currentPassword"
type="password"
v-model="form.currentPassword"
label="Current password*"
lazy-rules
#input="clearServerError('currentPassword', $event)"
:rules="[
val => $v.form.currentPassword.required || 'Confirm password',
val => $v.form.currentPassword.serverError || form.serverErrors.currentPassword
]"
/>
...
<div>
<q-btn label="Save" type="submit" color="primary" />
</div>
</q-form>
</template>
This all works perfectly fine, however it seems to be very WET (not DRY). I have to define the following things for each field manually:
serverError: this.serverError('name') in validation
A rule val => $v.form.name.serverError || form.serverErrors.name in the template
#input="clearServerError('name', $event)" in the template
But I know I want to do this for every input in my form component, so it feels very repetitive to do this manually for every input component. Is there a correct "Vue-ish" way to DRY this up?
One thing I tried is to find all input fields by traversing all descendants of my form component using $children. Then:
I tried to tackle 1., by defining these server errors dynamically by looping over all input components.
2. is harder to tackle since you can not directly update the property :rules since Vue will override it on a new render of the parent component. (This also throws a warning, saying you should not directly update properties, but use v-model etc.)
I tried to tackle 3. by defining input event listeners dynamically from my Form component using the $on method on the actual input components.
The DRY-up for 1 and 3 seem to work, but is it the best way of doing it? And what is a correct approach of drying up 2?

Related

Vue: Input Manual Autocomplete component

I have a vue-cli project, that has a component named 'AutoCompleteList.vue' that manually handled for searching experience and this component has some buttons that will be fill out the input.
It listens an array as its item list. so when this array has some items, it will be automatically shown; and when I empty this array, it will be automatically hidden.
I defined an oninput event method for my input, that fetches data from server, and fill the array. so the autocomplete list, will not be shown while the user doesn't try to enter something into the input.
I also like to hide the autocomplete list when the user blurs the input (onblur).
but there is a really big problem! when the user chooses one of items (buttons) on the autocomplete list, JS-engine first blurs the input (onblur runs) and then, tries to run onclick method in autocomplete list. but its too late, because the autocomplete list has hidden and there is nothing to do. so the input will not fill out...
here is my code:
src/views/LoginView.vue:
<template>
<InputGroup
label="Your School Name"
inputId="schoolName"
:onInput="schoolNameOnInput"
autoComplete="off"
:onFocus="onFocus"
:onBlur="onBlur"
:vModel="schoolName"
#update:vModel="newValue => schoolName = newValue"
/>
<AutoCompleteList
:items="autoCompleteItems"
:choose="autoCompleteOnChoose"
v-show="autoCompleteItems.length > 0"
:positionY="autoCompletePositionY"
:positionX="autoCompletePositionX"
/>
</template>
<script>
import InputGroup from '../components/InputGroup'
import AutoCompleteList from '../components/AutoCompleteList'
export default {
name: 'LoginView',
components: {
InputGroup,
AutoCompleteList
},
props: [],
data: () => ({
autoCompleteItems: [],
autoCompletePositionY: 0,
autoCompletePositionX: 0,
schoolName: ""
}),
methods: {
async schoolNameOnInput(e) {
const data = await (await fetch(`http://[::1]:8888/schools/${e.target.value}`)).json();
this.autoCompleteItems = data;
},
autoCompleteOnChoose(value, name) {
OO("#schoolName").val(name);
this.schoolName = name;
},
onFocus(e) {
const position = e.target.getBoundingClientRect();
this.autoCompletePositionX = innerWidth - position.right;
this.autoCompletePositionY = position.top + e.target.offsetHeight + 20;
},
onBlur(e) {
// this.autoCompleteItems = [];
// PROBLEM! =================================================================
}
}
}
</script>
src/components/AutoCompleteList.vue:
<template>
<div class="autocomplete-list" :style="'top: ' + this.positionY + 'px; right: ' + this.positionX + 'px;'">
<ul>
<li v-for="(item, index) in items" :key="index">
<button #click="choose(item.value, item.name)" type="button">{{ item.name }}</button>
</li>
</ul>
</div>
</template>
<script>
export default {
name: 'AutoCompleteList',
props: {
items: Array,
positionX: Number,
positionY: Number,
choose: Function
},
data: () => ({
})
}
</script>
src/components/InputGroup.vue:
<template>
<div class="input-group mb-3">
<label class="input-group-text" :for="inputId ?? ''">{{ label }}</label>
<input
:type="type ?? 'text'"
:class="['form-control', ltr && 'ltr']"
:id="inputId ?? ''"
#input="$event => { $emit('update:vModel', $event.target.value); onInput($event); }"
:autocomplete="autoComplete ?? 'off'"
#focus="onFocus"
#blur="onBlur"
:value="vModel"
/>
</div>
</template>
<script>
export default {
name: 'input-group',
props: {
label: String,
ltr: Boolean,
type: String,
inputId: String,
groupingId: String,
onInput: Function,
autoComplete: String,
onFocus: Function,
onBlur: Function,
vModel: String
},
emits: [
'update:vModel'
],
data: () => ({
}),
methods: {
}
}
</script>
Notes on LoginView.vue:
autoCompletePositionX and autoCompletePositionY are used to find the best position to show the autocomplete list; will be changed in onFocus method of the input (inputGroup)
OO("#schoolName").val(name) is used to change the value of the input, works like jQuery (but not exactly)
the [::1]:8888 is my server that used to fetch the search results
If there was any unclear code, ask me in the comment
I need to fix this. any idea?
Thank you, #yoduh
I got the answer.
I knew there should be some differences between when the user focus out the input normally, and when he tries to click on buttons.
the key, was the FocusEvent.relatedTarget property. It should be defined in onblur method. here is its full tutorial.
I defined a property named isFocus and I change it in onBlur method, only when I sure that the focus is not on the dropdown menu, by checking the relatedTarget

Vue - Keep input focused on route change

I have a TopNavBar component, that is present on every route. This component includes a search input field. When a user clicks on the input field the route changes from /bar to /foo but input focus is lost. How can I (re)focus on the input?
TopNavBar.vue
<template>
<input type="search" name="search-library" v-focus ref="searchInput" #focus="initSearch". />
</template>
<script setup>
const searchInput = ref(null);
<input type="search" name="search-library" v-focus ref="searchInput" #focus="initSearch". />
function initSearch() {
if (router.currentRoute.value.name != "/foo") {
router.push({ path: "/foo", query: { initSearch: true }, key: route.fullPath });
}
}
watch(
() => router.currentRoute.value.path,
(newRoute) => {
if (newRoute == "/foo") {
searchInput.value.focus();
}
}
);
</script>
I'm using Vue3 and Nuxt3. v-focusz directive is declared globally in /plugins` folder and works as expected.
Update
TopNavBar is inside Nuxt 3 layout. Also, upon further investigation I've realised that the input does focus on route change but immediately loses it again.
You can achieve this by using $refs, Attach a reference on input element and then call focus method on it.
In template:
<parent-component>
<search-component ref="searchComponentRef" />
</parent-component>
In script:
mounted() {
this.$refs.searchComponentRef.$el.focus();
}

Vue2: Use form component with input type textarea to display AND edit data (without directly manipulating props)

I am building an MVP and this is the first time I do web development. I am using Vue2 and Firebase and so far, things go well.
However, I ran into a problem I cannot solve alone. I have an idea how it SHOULD work but cannot write it into code and hope you guys can help untangle my mind. By now I am incredibly confused and increasingly frustrated :D
So lets see what I got:
Child Component
I have built a child component which is a form with three text-areas. To keep it simple, only one is included it my code snippets.
<template>
<div class="wrap">
<form class="form">
<p class="label">Headline</p>
<textarea rows="2"
v-model="propHeadline"
:readonly="readonly">
</textarea>
// To switch between read and edit
<button
v-if="readonly"
#click.prevent="togglemode()">
edit
</button>
<button
v-else
type="submit"
#click.prevent="togglemode(), updatePost()"
>
save
</button>
</form>
</div>
</template>
<script>
export default {
name: 'PostComponent'
data() {
return {
readonly: true
}
},
props: {
propHeadline: {
type: String,
required: true
}
},
methods: {
togglemode() {
if (this.readonly) {
this.readonly = false
} else {
this.readonly = true
}
},
updatePost() {
// updates it to the API - that works
}
}
}
</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.5.17/vue.js"></script>
And my parent component:
<template>
<div class="wrap">
<PostComponent
v-for="post in posts"
:key="post.id"
:knugHeadline="post.headline"
/>
</div>
</template>
<script>
import PostComponent from '#/components/PostComponent.vue'
export default {
components: { PostComponent },
data() {
return {
posts: []
}
},
created() {
// Gets all posts from DB and pushes them in array "posts"
}
</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.5.17/vue.js"></script>
Current Status
So far, everything works. I can display all posts and when clicking on "edit" I can make changes and save them. Everything gets updated to Firebase - great!
Problem / Error Message
I get the following error message:
[Vue warn]: Avoid mutating a prop directly since the value will be overwritten whenever the parent component re-renders. Instead, use a data or computed property based on the prop's value.
As the error says I should use a computed property based on the props value. But how can I achieve that?
Solution Approach
I believe I have to use a computed getter to return the prop value - how to do that?
And then I have to use the setter to emit an event to the parent to update the value so the prop passes it back down - how to do that?
I have found bits and pieces online but by now all I see is happy families passing around small packages of data...
Would be really thankful for a suggestion on how to solve this one! :)
Thanks a lot!
This error shows because of your v-model on texterea which mutate the prop, but in vue it is illegal to mutate props :
<textarea rows="2"
v-model="propHeadline"
:readonly="readonly">
</textarea>
So, what you could do is to use this created() lifecycle hook and set the propHeadline prop as data :
<script>
export default {
name: 'PostComponent'
data() {
return {
readonly: true,
headline: ""
}
},
props: {
propHeadline: {
type: String,
required: true
}
},
created() {
this.headline = this.propHeadline
}
}
</script>
An then, update the new variable on your textarea :
<textarea rows="2"
v-model="headline"
:readonly="readonly">
</textarea>

Jest/Enzyme | Test a mask/unmask Password function

I have a form, in my react component, that has 2 fields that call a function, that on click show button masks and unmasks the specific fields. Basically, I need some help on how to test the function itself.
The Function:
togglePasswordMask = e => {
const { type } = this.state;
e.preventDefault();
this.setState(prevState => ({
passwordIsMasked: !prevState.passwordIsMasked,
type: type === 'password' ? 'input' : 'password'
}));
};
I call that function, in my render method like this:
<div className="input-group mb-3">
<Field
type={type}
className={classNames('form-control', {
'is-invalid': errors.password && touched.password
})}
placeholder="Password (Required)"
name="password"
/>
<div className="input-group-append">
<span className="input-group-text">
<div
className={type === 'password' ?
'fa fa-eye fa-lg' : 'fa fa-eye-slash fa-lg'}
onClick={this.togglePasswordMask}
/>
</span>
</div>
</div>
It also has an INITIAL_STATE:
state = {
type: 'password',
groups: []
};
Can you help me, write the test cases for this, using Jest and Enzyme. I tried the following, but they don't seem to work:
describe('UserCreateForm TogglePassword', () => {
it('Should unmask password and confirmPassword on click', () => {
const maskElement = wrapper.find('.fa fa-eye fa-lg');
const maskFn = maskElement.simulate('click');
expect(maskFn().state()).toEqual('input');
});
});
I get this error: TypeError: Cannot read property 'preventDefault' of undefined.
I iterated a little bit after I found another answer, and my test now looks something like this:
it('Should unmask password and confirmPassword on click', () => {
console.log(wrapper.debug());
const maskElement = wrapper.find('.fa-eye');
const maskFn = maskElement.simulate('click', {
preventDefault: () => {}
});
expect(maskFn().state()).toEqual('input');
});
And now, I get another error: maskFn, is not a function.
your immediate problem is because maskElement.simulate returns Self according to the Enzyme docs, and that's an object not a function. Get rid of maskFn completely, call maskElement.simulate and ignore its return value, and just run your expect against maskElement.state().
(Also, instead of testing against your component's internal state - which some feel to be an anti-pattern because you're testing component implementation and not component behavior - consider expecting the component to render a <Field type="password" /> vs a <Field type="text" />)

VueJS - Element UI Input Component - Event handler "input" error

I'm trying to create a custom component with VueJS & Element-UI and I'm getting a very annoying error when trying to enter data into the input field.
Below are the files & the contents related to the issue:
components.js file:
Vue.component('yetti-input', {
props: ['value'],
template: '<el-input ref="input" v-bind:value="value" v-on:input="parseValue($event.target.value)"></el-input>',
methods: {
parseValue (value) {
this.$emit('input', value)
}
}
})
index.vue file:
<template>
<div>
<div class="login-form">
<yetti-form>
<yetti-input v-model="login.email"></yetti-input>
</yetti-form>
</div>
</div>
</template>
<script>
export default {
data () {
return {
login: {
email: '',
password: ''
}
}
}
}
</script>
Error I'm receiving in the Console:
Please point out if I'm being a fool, however I cannot for the life of me figure out what is going on.
Cheers,
Tim
Okay, I solved my problem.
Interestingly, the $event is the input value being provided when using el-input.
Rather than have: v-on:input="parseValue($event.target.value)"
I removed target.value and I had my value.
v-on:input="parseValue($event)"
Not sure if I've done the wrong thing by VueJS here. However, this has resolved my issue.

Categories

Resources