I have used VueJS and Vuex before. Not in production, just for some simple, personal side projects and it felt pretty straight forward.
However, now I face a problem that is just slightly more complex but using Vuex already feels way more complicated. So I'm looking for some guidance here.
In the main view I present a list of cards to the user. A card is an editable form of about ten properties (inputs, selects, etc.). I obviously implemented a component for these cards, since they are repeated in a list view. My first naive approach was to fetch a list of - let's call it forms - from my store and then using v-for to present a card for each form in that list of forms. The form is passed to the child component as a property.
Now I want to bind my form controls to the properties. To do this I implemented Two-way Computed Properties to properly utilize my store mutations. But it feels extremely repetitive to implement a custom computed property with getter and setter plus mutation for every property on my model. Additionally, the forms are an array in my state. So I have to pass the id of the form to edit in the store to every mutation.
Something else I had in mind was just passing the store id of the form to the child component and have "by id" getters for every property of my model as well as a matching mutation. But this doesn't feel like the proper way to do this, either. It is essentially the same, right?!
Is there a better solution to this problem? Maybe I'm just missing something or I'm overcomplicating things.
A trimmed down example:
Editor.vue:
<template>
<v-container>
<EditableCard v-for="(card, i) in cards" :key="i" :card="card" />
</v-container>
</template>
<script>
import EditableCard from "#/components/EditableCard";
import { mapGetters } from "vuex";
export default {
name: "Editor",
components: {
EditableCard
},
computed: {
...mapGetters("cards", {
cards: "list"
})
}
};
</script>
EditableCard:
<template>
<v-card>
<v-form>
<v-card-title>
<v-text-field v-model="title"></v-text-field>
</v-card-title>
<v-card-text>
<v-text-fieldv-model="text"></v-text-field>
<!-- And some more fields... -->
</v-card-text>
</v-form>
</v-card>
</template>
<script>
import { mapMutations } from "vuex";
export default {
name: "EditableCard",
props: {
card: Object
},
computed: {
title: {
get() {
return card.title;
},
set(value) {
this.setCardTitle(this.card.id, value);
}
},
text: {
get() {
return card.text;
},
set(value) {
this.setCardText(this.card.id, value);
}
}
// Repeat for every form input control
},
methods: {
...mapMutations("cards", {
setCardTitle: "setTitle",
setCardText: "setText"
// Repeat for every form input control
})
}
};
</script>
It would be nice to create a computed setter for the whole form object using a clone, but this won't work because changes won't trigger the computed setter.
If anyone wants to explore this interesting failure, see here)
To work around this, you can use a watch and a data clone:
<v-form>
<v-text-field v-model="clone.title" />
<v-text-field v-model="clone.text" />
</v-form>
props: ['index', 'card'],
data() {
return {
clone: {}
}
},
watch: {
card: {
handler(card) {
this.clone = { ...card }
},
immediate: true,
deep: true
},
clone: {
handler(n,o) {
if (n === o) {
this.$store.commit('SET_CARD', { index: this.index, card: n })
}
},
deep: true
}
}
Your v-for:
<EditableCard v-for="(card, index) in cards" :card="card" :index="index" :key="index" />
The mutation:
mutations: {
SET_CARD(state, { index, card }) {
Vue.set(state.cards, index, card);
}
}
This is way more complex than it should need to be... but it works.
Related
I am trying to have a child component update its props that were passed from the parents at the start of the rendering. Since the value is coming from a fetch call, it takes a bit of time to get the value, so I understand that the child component will receive a 'null' variable. But once the fetch call is completed, the value is updated but the child component still has the original null value.
During my search for a solution, I found that another way was to use Vuex Stores, so I implemented it with the count variable and had a button to call a commit and later dispatch with an action function to the store to increment it's value but when the increment happens, it doesn't show the new value on the screen even though with console logs I confirmed it did change the value when the function was called.
I guess I don't fully understand how to update the value of a variable without reassigning it within it's own component or having to call a separate function manually right after I change the value of a data variable.
App.vue
<template>
<div id="app">
<div id="banner">
<div>Title</div>
</div>
<p>count: {{count}}</p> // a small test i was doing to figure out how to update data values
<button #click="update">Click </button>
<div id="content" class="container">
<CustomDropdown title="Title Test" :valueProps="values" /> // passing the data into child component
</div>
</div>
</template>
<script>
import CustomDropdown from './components/CustomDropdown.vue'
export default {
name: 'App',
components: {
CustomDropdown,
},
data() {
return {
values: null
count: this.$store.state.count
}
},
methods: {
update() {
this.$store.dispatch('increment')
}
},
async created() {
const response = await fetch("http://localhost:3000/getIds", {
method: 'GET',
headers: {
'Accept': 'application/json, text/plain, */*',
'Content-Type': 'application/json'
}
});
const data = await response.json();
this.values = data // This is when I expect the child component to rerender and show the new data. data is an array of objects
console.log("data", data, this.values) // the console log shows both variables have data
}
}
</script>
CustomDropDown.vue
<template>
<div id="dropdown-container" class="">
<b-dropdown class="outline danger" variant="outline-dark" :text="title" :disabled="disabled">
<b-dropdown-item
v-for="value in values"
:key="value.DIV_ID"
href="#">
{{value.name}}
</b-dropdown-item>
</b-dropdown>
</div>
</template>
<script>
export default {
name: 'CustomDropdown',
components: {},
props: {
title: String,
valuesProp: Array,
disabled: Boolean
},
data() {
return {
values: this.valuesProp
}
},
methods: {
},
created() {
console.log("dropdown created")
console.log(this.valuesProp) //Always undefined
}
}
</script>
store.js
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
export default new Vuex.Store({
state() {
return {
count: 0,
divisionIds: []
}
},
mutations: {
increment (state) {
console.log("count", state.count)
state.count++
}
},
actions: {
increment (state) {
console.log("count action", state.count)
state.commit('increment')
}
}
})
data in your child component CustomDropdown.vue is not reactive: therefore the value of this.values is not updated when the prop changes. If you want to alias a prop, use computed instead:
export default {
name: 'CustomDropdown',
components: {},
props: {
title: String,
valuesProp: Array,
disabled: Boolean
},
computed: {
values() {
return this.valuesProp;
}
},
created() {
console.log("dropdown created");
}
}
If you want to console log the most updated values of this.valuesProp, you will need to watch it: the same if you want for this.values.
One thing you can do is to use a v-if in your child component to only render it after you get your result from you api.
It would be something like:
<CustomDropdown title="Title Test" :valueProps="values" v-if="values"/>
This way you would make sure that your child component gets rendered only when values are available.
It would only be a bad solution if this api call took so long and you needed to display the child component data to the user before that.
Hey you can simply watch it your child component
watch: { valuesProp: function(newVal, oldVal) { // watch it if(newVal.length > 0) do something }
it will watch for the value changes and when you get your desired value you can perform whatever hope it will help you you dont need store or conditional binding for it.
I have a 'client-display' component containing a list of clients that I get in my store via mapGetter. I use 'v-for' over the list to display all of them in vuetify 'v-expansion-panels', thus one client = one panel. In the header of those panels, I have a 'edit-delete' component with the client passed to as a prop. This 'edit-delete' basically just emits 'edit' or 'delete' events when clicked on the corresponding icon with the client for payload. When I click on the edit icon, the edit event is then catched in my 'client-display' so I can assign the client to a variable called 'client' (sorry I know it's confusing a bit). I pass this variable to my dialog as a prop and I use this dialog to edit the client.
So the probleme is : When I edit a client, it does edit properly, but if I click on 'cancel', I find no way to revert what happened in the UI. I tried keeping an object with the old values and reset it on a cancel event, but no matter what happens, even the reference values that I try to keep in the object change, and this is what is the most surprising to me. I tried many things for this, such as initiating a new object and assigning the values manually or using Object.assign(). I tried a lot of different ways to 'unbind' all of this, nothing worked out. I'd like to be able to wait for the changes to be commited in the store before it's visible in the UI, or to be able to have a reference object to reset the values on a 'cancel' event.
Here are the relevant parts of the code (I stripped a lot of stuff to try and make it easier to read, but I think everything needed is there):
Client module for my store
I think this part works fine because I get the clients properly, though maybe something is binded and it should not
const state = {
clients: null,
};
const getters = {
[types.CLIENTS] : state => {
return state.clients;
},
};
const mutations = {
[types.MUTATE_LOAD]: (state, clients) => {
state.clients = clients;
},
};
const actions = {
[types.FETCH]: ({commit}) => {
clientsCollection.get()
.then((querySnapshot) => {
let clients = querySnapshot.docs.map(doc => doc.data());
commit(types.MUTATE_LOAD, clients)
}).catch((e) => {
//...
});
},
}
export default {
state,
getters,
mutations,
...
}
ClientsDisplay component
<template>
<div>
<div>
<v-expansion-panels>
<v-expansion-panel
v-for="c in clientsDisplayed"
:key="c.name"
>
<v-expansion-panel-header>
<div>
<h2>{{ c.name }}</h2>
<edit-delete
:element="c"
#edit="handleEdit"
#delete="handleDelete"
/>
</div>
</v-expansion-panel-header>
<v-expansion-panel-content>
//the client holder displays the client's info
<client-holder
:client="c"
/>
</v-expansion-panel-content>
</v-expansion-panel>
</v-expansion-panels>
</div>
<client-add-dialog
v-model="clientPopup"
:client="client"
#cancelEdit="handleCancel"
/>
</div>
</template>
<script>
import { mapGetters, mapActions } from 'vuex';
import * as clientsTypes from '../../../../store/modules/clients/types';
import ClientDialog from './ClientDialog';
import EditDelete from '../../EditDelete';
import ClientHolder from './ClientHolder';
import icons from '../../../../constants/icons';
export default {
name: 'ClientsDisplay',
components: {
ClientHolder,
ClientAddDialog,
EditDelete,
},
data() {
return {
icons,
clientPopup: false,
selectedClient: null,
client: null,
vueInstance: this,
}
},
created() {
this.fetchClients();
},
methods: {
...mapGetters({
'stateClients': clientsTypes.CLIENTS,
}),
...mapActions({
//this loads my clients in my state for the first time if needed
'fetchClients': clientsTypes.FETCH,
}),
handleEdit(client) {
this.client = client;
this.clientPopup = true;
},
handleCancel(payload) {
//payload.uneditedClient, as defined in the dialog, has been applied the changes
},
},
computed: {
isMobile,
clientsDisplayed() {
return this.stateClients();
},
}
}
</script>
EditDelete component
<template>
<div>
<v-icon
#click.stop="$emit('edit', element)"
>edit</v-icon>
<v-icon
#click.stop="$emit('delete', element)"
>delete</v-icon>
</div>
</template>
<script>
export default {
name: 'EditDelete',
props: ['element']
}
</script>
ClientDialog component
Something to note here : the headerTitle stays the same, even though the client name changes.
<template>
<v-dialog
v-model="value"
>
<v-card>
<v-card-title
primary-title
>
{{ headerTitle }}
</v-card-title>
<v-form
ref="form"
>
<v-text-field
label="Client name"
v-model="clientName"
/>
<address-fields
v-model="clientAddress"
/>
</v-form>
<v-card-actions>
<v-btn
#click="handleCancel"
text
>Annuler</v-btn>
<v-btn
text
#click="submit"
>Save</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script>
import AddressFields from '../../AddressFields';
export default {
name: 'ClientDialog',
props: ['value', 'client'],
components: {
AddressFields,
},
data() {
return {
colors,
clientName: '',
clientAddress: { province: 'QC', country: 'Canada' },
clientNote: '',
uneditedClient: {},
}
},
methods: {
closeDialog() {
this.$emit('input', false);
},
handleCancel() {
this.$emit('cancelEdit', { uneditedClient: this.uneditedClient, editedClient: this.client})
this.closeDialog();
},
},
computed: {
headerTitle() {
return this.client.name
}
},
watch: {
value: function(val) {
// I watch there so I can reset the client whenever I open de dialog
if(val) {
// Here I try to keep an object with the value of this.client before I edit it
// but it doesn't seem to work as I intend
Object.assign(this.uneditedClient, this.client);
this.clientName = this.client.name;
this.clientContacts = this.client.contacts;
this.clientAddress = this.client.address;
this.clientNote = '';
}
}
}
}
</script>
To keep an independent copy of the data, you'll want to perform a deep copy of the object using something like klona. Using Object.assign is a shallow copy and doesn't protect against reference value changes.
I have an application that uses a form contained within a child component. A prop (wizardData) is passed to the child from the parent. If the form is being used to enter a new set of data, the field values in that prop will be null; if the user is reviewing/editing stored data, the field values contain these stored data values.
My problem occurs in the latter scenario. Although the form's fields are populated with the stored values, when I click to edit any field (I've only shown two of several in the code below), all the values in the form disappear and the $emit call updates the parent with null values.
What am I doing wrong here?
Thanks,
Tom
child Component
<template>
<div>
<form #input="submit" class="form">
<v-card-text>
<v-text-field
model='wizardData.product'
type="text"
label="Name"
box
max="100%"
autofocus
></v-text-field>
<v-text-field
model='wizardData.source'
type="text"
label="Source"
box
></v-text-field>
</v-card-text>
</div>
</template>
<script>
export default {
props: {
wizardData: {
type: Object,
required: true
}
},
data() {
return {
form: {
product: null,
source: null,
}
}
},
submit () {
this.$emit('update', {
data: {
product: this.form.product,
source: this.form.source,
},
})
},
}
}
</script>
My prop is as follows:
wizardData
{
"product": "Cucumber",
"source": "D112",
}
Binding your input field models to the wizardData prop violates Vue's One-Way Data Flow.
You should initialise your component's local data as a copy of the prop and bind your field models to form. For example
// default form values in case they're missing from wizardData
const FORM_TEMPLATE = {
product: null,
source: null
}
export default {
props: { wizardData: Object },
data () {
return {
form: {...FORM_TEMPLATE, ...this.wizardData}
}
},
methods: {
submit() {
this.$emit('update', { data: this.form })
}
}
}
and in your template
<v-text-field v-model="form.product" ... />
JSFiddle demo ~ https://jsfiddle.net/z04um7Lb/
If wizardData is altered externally to your component, you will need to set up a watcher to monitor changes in the prop, eg
watch: {
wizardData (newData) {
this.form = {...this.form, ...newData}
}
}
Another (and possibly better) option would be to use conditional rendering to prevent the form component from displaying until the data is ready, eg
<form-component :wizard-data="wizardData" v-if="dataLoaded" />
I wrote a simple template-substitution component in VueJS as a single-file component. It doesn't have many features: just one prop, and I also made a computed property to encapsulate some tricky transformations that are done to that prop before it can be used in the template. It looks something like the following:
<template>
...some-html-here...
<a :href="myHref">...</a>
...
</template>
<script>
export default {
name: 'MyComponent',
props: {
href: { type: String, required: true },
},
computed: {
myHref() {
let result = this.href;
// several lines of complicated logic making substitutions and stuff
// ...
return result;
}
}
};
</script>
Now I think this should really be a functional component, as it has no state, no data, no reactivity, and so lunking around a whole instance is wasteful.
I can make this functional just by adding the 'functional' attribute to my <template>. In a functional component, of course, there are no such things as computed properties or methods or whatever. So my question is: where can I put my several lines of complicated logic? I don't want to have to embed this directly into my template, especially as it is used in multiple places. So where can I put code to transform my input props and make them ready to use in my template?
Great question.I was trying to find the same answer and i ended up with the following which i don't know if it is a good way to do though.
The "html" part:
<template functional>
<div>
<button #click="props.methods.firstMethod">Console Something</button>
<button #click="props.methods.secondMethod">Alert Something</button>
</div>
</template>
The "js" part:
<script>
export default {
props: {
methods: {
type: Object,
default() {
return {
firstMethod() {
console.log('You clicked me')
},
secondMethod() {
alert('You clicked me')
}
}
}
}
}
}
</script>
See it in action here
Make sure to read about functional components at docs
NOTE: Be aware using this approach since functional components are stateless (no reactive data) and instanceless (no this context).
I understand the .sync modifier returned in Vue 2.3, and am using it for a simple child component which implements a 'multiple-choice' question and answer. The parent component calls the child like this:
<question
:stem="What is your favourite colour?"
:options="['Blue', 'No, wait, aaaaargh!']
:answer.sync="userChoice"
>
The parent has a string data element userChoice to store the result from the child component. The child presents the question and radio buttons for the options. The essential bits of the child look like this (I'm using Quasar, hence q-radio):
<template>
<div>
<h5>{{stem}}</h5>
<div class="option" v-for="opt in options">
<label >
<q-radio v-model="option" :val="opt.val" #input="handleInput"></q-radio>
{{opt.text}}
</label>
</div>
</div>
</template>
export default {
props: {
stem: String,
options: Array,
answer: String
},
data: () => ({
option: null
}),
methods: {
handleInput () {
this.$emit('update:answer', this.option)
}
}
}
This is all working fine, apart from the fact that if the parent then changes the value of userChoice due to something else happening in the app, the child doesn't update the radio buttons. I had to include this watch in the child:
watch: {
answer () {
this.option = this.answer
}
}
But it feels a little redundant, and I was worried that emitting the event to update the parent's data would in fact cause the child 'watch' event to also fire. In this case it would have no effect other than wasting a few cycles, but if it was logging or counting anything, that would be a false positive...
Maybe that is the correct solution for true 2-way binding (i.e. dynamic Parent → Child, as well as Child → Parent). Did I miss something about how to connect the 'in' and 'out' data on both sides?
In case you're wondering, the most common case of the parent wanting to change 'userChoice' would be in response to a 'Clear Answers' button which would set userChoice back to an empty string. That should have the effect of 'unsetting' all the radio buttons.
Your construction had some oddities that didn't work, but basically answer.sync works if you propagate it down to the q-radio component where the changing happens. Changing the answer in the parent is handled properly, but to clear values, it seems you need to set it to an object rather than null (I think this is because it needs to be assignable).
Update
Your setup of options is a notable thing that didn't work.
I use answer in the q-radio to control its checked state (v-model has special behavior in a radio, which is why I use value in conjunction with v-model). From your comment, it looks like q-radio wants to have a value it can set. You ought to be able to do that with a computed based on answer, which you would use instead of your option data item: the get returns answer, and the set does the emit. I have updated my snippet to use the val prop for q-radio plus the computed I describe. The proxyAnswer emits an update event, which is what the .sync modifier wants. I also implemented q-radio using a proxy computed, but that's just to get the behavior that should already be baked-into your q-radio.
(What I describe is effectively what you're doing with a data item and a watcher, but a computed is a nicer way to encapsulate that).
new Vue({
el: '#app',
data: {
userChoice: null,
options: ['Blue', 'No, wait, aaaaargh!'].map(v => ({
value: v,
text: v
}))
},
components: {
question: {
props: {
stem: String,
options: Array,
answer: String
},
computed: {
proxyAnswer: {
get() {
return this.answer;
},
set(newValue) {
this.$emit('update:answer', newValue);
}
}
},
components: {
qRadio: {
props: ['value', 'val'],
computed: {
proxyValue: {
get() {
return this.value;
},
set(newValue) {
this.$emit('input', newValue);
}
}
}
}
}
}
},
methods: {
clearSelection() {
this.userChoice = {};
}
}
});
<script src="//cdnjs.cloudflare.com/ajax/libs/vue/2.3.3/vue.min.js"></script>
<div id="app">
<question stem="What is your favourite colour?" :options="options" :answer.sync="userChoice" inline-template>
<div>
<h5>{{stem}}</h5>
<div class="option" v-for="opt in options">
<div>Answer={{answer && answer.text}}, option={{opt.text}}</div>
<label>
<q-radio :val="opt" v-model="proxyAnswer" inline-template>
<input type="radio" :value="val" v-model="proxyValue">
</q-radio>
{{opt.text}}
</label>
</div>
</div>
</question>
<button #click="clearSelection">Clear</button>
</div>