Changing complex computed object in vue.js - vue.js

I have complicated object for a table. Looks like this:
{
1510002000: {
date: "07.11.17"
hours: {
1510002000:{
activity:{
activity: "Тест",
color: "#00ff00",
end_at: 1510005600,
start_at: 1510002000,
type_id: 1
}
},
1510005600: {
...
},
...
}
},
....
}
This is a code from template that uses this object:
<tr v-for="date in this.tds">
<td>{{ date.date }}</td>
<td is="hour-td"
v-for="hour in date.hours"
:color="hour.activity.color"
:start_at="hour.activity.start_at"
:end_at="hour.activity.end_at"
:activity="hour.activity.activity"
:type_id="hour.activity.type_id"
>
</td>
</tr>
I evaluated it as a computed property, but I need to rerender table when parent component provides data assync, so I have a watcher for prop (prop called "activities"):
watch: {
activities: function(){
var vm = this;
let dth = new DateTimeHelper;
if (this.activities.length > 0){
this.activities.forEach(function(activity){
let dateTimestamp = dth.getDateTimestampFromTimestamp(activity.start_at); // just getting the key
if (vm.tds[dateTimestamp]){
if (vm.tds[dateTimestamp].hours[activity.start_at]){
vm.tds[dateTimestamp].hours[activity.start_at].activity.activity = activity.activity;
vm.tds[dateTimestamp].hours[activity.start_at].activity.color = activity.color;
vm.tds[dateTimestamp].hours[activity.start_at].activity.type_id = activity.type_id;
}
}
});
}
console.log(vm.tds) // here I can see that the object is filled with new data
}
},
The problem is that the table doesn't rerender. More precisely, the component "hour-td" does not contain the new data.
Also I've tried to use Vue.set, but no success with that
Can you help me with the updating table? I've spent like 5 hours for refactoring and attempts.
Thanks in advance
SOLUTION
In my case there can be two states: there are activities, there are no activities. So I made two computed props for each case and render them separately and switch by v-if="activities.length"

I think that your problem is with Vue known issue for Change Detection Caveats (you can read here) with array direct assignation, that don't detect changes.
You should change this part of code (with direct array assignation):
if (vm.tds[dateTimestamp]){
if (vm.tds[dateTimestamp].hours[activity.start_at]){
vm.tds[dateTimestamp].hours[activity.start_at].activity.activity = activity.activity;
vm.tds[dateTimestamp].hours[activity.start_at].activity.color = activity.color;
vm.tds[dateTimestamp].hours[activity.start_at].activity.type_id = activity.type_id;
}
}
With the Vue.set() option for arrays in order to detect the change and re-renders the component. It worked for me in differents occassions:
// Vue.set(object, key, value)
// something like this:
Vue.set(vm.tds[dateTimestamp].hours[activity.start_at].activity.activity,1,activity.activity);
More info here: https://codingexplained.com/coding/front-end/vue-js/array-change-detection
Edit:
I see now that you said:
Also I've tried to use Vue.set, but no success with that
What you mean with: "no success" ? Can you share the code? I have the same issue and I resolved with Vue.set..
You can also take a look to vm.$forceUpdate(), try to execute after the last console.log or grab all the code inside a vm.$nextTick( [callback] ) in order to execute all the actions (load data in the table) and then, re-render the component on next tick.
More info here: https://v2.vuejs.org/v2/api/#vm-forceUpdate && https://v2.vuejs.org/v2/api/#Vue-nextTick
Edit 2:
I think that your problem is with the index of the array, you should take a look here: https://v2.vuejs.org/v2/guide/list.html#Array-Change-Detection .
Try changing the:
if (vm.tds[dateTimestamp]){
if (vm.tds[dateTimestamp].hours[activity.start_at]){
vm.tds[dateTimestamp].hours[activity.start_at].activity.activity = activity.activity;
vm.tds[dateTimestamp].hours[activity.start_at].activity.color = activity.color;
vm.tds[dateTimestamp].hours[activity.start_at].activity.type_id = activity.type_id;
}
}
and simplify with:
if (vm.tds[dateTimestamp] && vm.tds[dateTimestamp].hours[activity.start_at]){
Vue.set( vm.tds, vm.tds.indexOf(vm.tds[dateTimestamp].hours[activity.start_at]), activity);
}
Hope it helps!

Related

Vuejs - update array of an object which is in an array

I'm developing a helpdesk tool in which I have a kanban view.
I previously used nested serializers in my backend and I managed to have everything working with a single query but it's not scalable (and it was ugly) so I switched to another schema :
I query my helpdesk team ('test' in the screenshot)
I query the stages of that team ('new', 'in progress')
I query tickets for each stage in stages
So when I mount my component, I do the following :
async mounted () {
if (this.helpdeskTeamId) {
await this.getTeam(this.helpdeskTeamId)
if (this.team) {
await this.getTeamStages(this.helpdeskTeamId)
if (this.stages) {
for (let stage of this.stages) {
await this.getStageTickets(stage)
}
}
}
}
},
where getTeam, getTeamStages and getStageTickets are :
async getTeam (teamId) {
this.team = await HelpdeskTeamService.getTeam(teamId)
},
async getTeamStages (teamId) {
this.stages = await HelpdeskTeamService.getTeamStages(teamId)
for (let stage of this.stages) {
this.$set(stage, 'tickets', [])
}
},
async getStageTickets (stage) {
const tickets = await HelpdeskTeamService.getTeamStageTickets(this.helpdeskTeamId, stage.id)
// tried many things here below but nothing worked.
// stage.tickets = stage.tickets.splice(0, 0, tickets)
// Even if I try to only put one :
// this.$set(this.stages[this.stages.indexOf(stage)].tickets, 0, tickets[0])
// I see it in the data but It doesn't appear in the view...
// Even replacing the whole stage with its tickets :
// stage.tickets = tickets
// this.stages.splice(this.stages.indexOf(stage), 1, stage)
},
In getTeamStages I add an attribute 'tickets' to every stage to an empty list. The problem is when I query all the tickets for every stage. I know how to insert a single object in an array with splice or how to delete one object from an array but I don't know how to assign a whole array to an attribute of an object that is in an array while triggering the Vue reactivity. Here I'd like to put all the tickets (which is a list), to stage.tickets.
Is it possible to achieve this ?
If not, what is the correct design to achieve something similar ?
Thanks in advance !
EDIT:
It turns out that there was an error generated by the template part. I didn't think it was the root cause since a part of the view was rendered. I thought that it would have prevent the whole view from being rendered if it was the case. But finally, in my template I had a part doing stage.tickets.length which was working when using a single query to populate my view. When making my API more granular and querying tickets independently from stages, there is a moment when stage has no tickets attribute until I set it manually with this.$set(stage, 'tickets', []). Because of that, the template stops rendering and raises an issue. But the ways of updating my stage.tickets would have worked without that template issue.
I could update the stages reactively. Here is my full code; I used the push method of an array object and it works:
<template>
<div>
<li v-for="item in stages" :key="item.stageId">
{{ item }}
</li>
</div>
</template>
<script>
export default {
data() {
return {
stages: [],
};
},
methods: {
async getTeamStages() {
this.stages = [{ stageId: 1 }, { stageId: 2 }];
for (let stage of this.stages) {
this.$set(stage, "tickets", []);
}
for (let stage of this.stages) {
await this.getStageTickets(stage);
}
},
async getStageTickets(stage) {
const tickets = ["a", "b", "c"];
for (let ticket of tickets) {
this.stages[this.stages.indexOf(stage)].tickets.push(ticket);
}
},
},
mounted() {
this.getTeamStages();
},
};
</script>
It should be noted that I used the concat method of an array object and also works:
this.stages[this.stages.indexOf(stage)].tickets = this.stages[this.stages.indexOf(stage)].tickets.concat(tickets);
I tried your approaches some of them work correctly:
NOT WORKED
this.$set(this.stages[this.stages.indexOf(stage)].tickets, tickets)
WORKED
this.$set(this.stages[this.stages.indexOf(stage)].tickets, 0, tickets[0]);
WORKED
stage.tickets = tickets
this.stages.splice(this.stages.indexOf(stage), 1, stage)
I'm sure it is XY problem..
A possible solution would be to watch the selected team and load the values from there. You seem to be loading everything from the mounted() hook, and I suspect this won't actually load all the content on demand as you'd expect.
I managed to make it work here without needing to resort to $set magic, just the pure old traditional vue magic. Vue will notice the properties of new objects and automatically make then reactive, so if you assign to them later, everything will respond accordingly.
My setup was something like this (showing just the relevant parts) -- typing from memory here, beware of typos:
data(){
teams: [],
teamId: null,
team: null
},
watch:{
teamId(v){
this.refreshTeam(v)
}
},
methods: {
async refreshTeam(id){
let team = await fetchTeam(id)
if(!team) return
//here, vue will auomaticlly make this.team.stages reactive
this.team = {stages:[], ...team}
let stages = await fetchStages(team.id)
if(!stages) return
//since this.team.stages is reactive, vue will update reactivelly
//turning the {tickets} property of each stage reactive also
this.team.stages = stages.map(v => ({tickets:[], ...v}))
for(let stage of this.team.stages){
let tickets = await fetchTickets(stage.id)
if(!tickets) continue
//since tickets is reactive, vue will update it accordingly
stage.tickets = tickets
}
}
},
async mounted(){
this.teams = fetchTeams()
}
Notice that my 'fetchXXX' methods would just return the data retrieved from the server, without trying to actually set the component data
Edit: typos

For each results in v-for loop how can I nest another v-for loop using a parameter from the results of the first loop

Using a v-for loop in Vue js. I am looping through the readingTasks data object which correctly produces two results from the data below.
readingTasks:Array[2]
0:Object
enabled:true
newunit:-1
task:"The part 3 guide"
unit:-1
unit_task_id:27
url:"#"
1:Object
enabled:true
newunit:-1
task:"The part 3 training units"
unit:-1
unit_task_id:28
url:"#"
The bit I am unsure about is how for each result, how do I run another Axios database call that shows if the reading Task is complete or not. For example for the first record, the complete status should be true (unit_task_id:27) and the second record should be false.
userTasks:Array[1]
0:Object
complete:true
enabled:true
newunit:-1
task:"The part 3 guide"
unit:-1
unit_task_id:27
unit_task_user_id:21
<ul>
<li v-for="task in readingTasks">
{{task.task}}
//trying to call a function that does an Axios call passing in parameters from readingTasks
{{getUserTaskByUnit(task.unit, task.unit_task_id)}}
<template v-for="usertask in userTasks">
{{usertask.complete}}
</template>
</li>
</ul>
//javascript if its useful
data: {
readingTasks: [],
userTasks: []
},
mounted() {
this.lastUnit();
},
methods: {
//functons
lastUnit: function() {
this.tasks();
},
tasks: function() {
var self = this;
var unit = this.unit;
axios.get("/WebService/units.asmx/GetTasks?unit=" + unit).then(function(response) {
self.readingTasks = response.data;
})
.catch(function(error) {
console.log(error);
})
.then(function() {
});
},
getUserTaskByUnit: function(unit, unitTaskId) {
var self = this;
axios.get("/WebService/units.asmx/GetUserTasks?unit=" + unit + "&unitTaskId=" + unitTaskId).then(function(response) {
self.userTasks = response.data;
})
.catch(function(error) {
console.log(error);
})
.then(function() {});
}
This code seems close to doing the correct thing, however {{usertask.complete}} flickers between true and false for both sets of results. Like it is stuck in a loop.
I would expect the first result to show True here and the second result to show False.
The part 3 guide - true
The part 3 training units - false
There are a few problems here.
The template has a dependency on userTasks, so every time userTasks changes it will cause the component to re-render, running the template again.
Every time the template runs it calls getUserTaskByUnit for both tasks. That will, asynchronously, update userTasks. When userTasks is updated it will trigger a re-render, which will call getUserTaskByUnit again, going round and round in an infinite loop.
Worse than just being an infinite loop, each time it renders it will trigger two requests, each of which will trigger another re-rendering. The number of requests will balloon exponentially.
When those requests do return you're then storing them in userTasks. But both responses are being stored in exactly the same place, so you'll only ever see the results of one request in the UI.
The first thing you'll need is a better data structure for storing the responses in getUserTaskByUnit. The simplest place to store them would be on the tasks in readingTask. That might look something like this:
// Note the whole task is now being passed to getUserTaskByUnit
getUserTaskByUnit: function(task) {
var self = this;
axios.get("/WebService/units.asmx/GetUserTasks?unit=" + task.unit + "&unitTaskId=" + task.unit_task_id).then(function(response) {
task.userTasks = response.data;
})
...
}
The call to getUserTaskByUnit needs moving out of the template. Moving it into the tasks method seems as good a place as any. There are also a few changes required to get it to work with the new version of getUserTaskByUnit:
tasks: function() {
var self = this;
var unit = this.unit;
axios.get("/WebService/units.asmx/GetTasks?unit=" + unit).then(function(response) {
var readingTasks = response.data;
// Pre-populate userTasks so it will be reactive
readingTasks.forEach(function(task) {
task.userTasks = [];
});
// This must come after userTasks is pre-populated
self.readingTasks = readingTasks;
readingTasks.forEach(function(task) {
// Passing task to getUserTaskByUnit, not unit and unit_task_id
self.getUserTaskByUnit(task);
});
})
...
Then within the template we'd need to loop over task.userTasks:
<ul>
<li v-for="task in readingTasks">
{{task.task}}
<template v-for="usertask in task.userTasks">
{{usertask.complete}}
</template>
</li>
</ul>
There are alternative data structures we could use depending on what other requirements you have. For example, you could retain a separate userTasks object to hold the userTasks but for that to work it would need to be a nested data structure rather than just an array. You'd need to key it by unit and then unitTaskId. The result in the template would be something like this:
<ul>
<li v-for="task in readingTasks">
{{task.task}}
<template v-for="usertask in userTasks[task.unit][task.unit_task_id]">
{{usertask.complete}}
</template>
</li>
</ul>
Much like with the earlier solution you would need to pre-populate the userTasks with empty values when readingTasks first loads to ensure the values are reactive and also to avoid the template blowing up at the undefined entries. Alternatively you could use $set and suitable v-if checks respectively.
This is all quite fiddly. It may be that you can simplify it a little based on your knowledge of the system. For example, it may be possible to form compound string keys for userTasks rather than using two levels of nesting. Or it might be that unit is a prop that can be considered constant and doesn't need including in that data structure.
Your userTasks is a view property and gets overwritten upon every call to getUserTaskByUnit (i.e. for each item in readingTasks). What you instead want is a nested structure. You should call getUserTaskByUnit in a loop as soon as readingTasks got loaded, i.e. after the line self.readingTasks = response.data;, and store the response as a property for every readingTask object.

How to prevent #change event when changing v-model value

I'm building an auto-complete menu in Vue.js backed by Firebase (using vue-fire). The aim is to start typing a user's display name and having match records show up in the list of divs below.
The template looks like this:
<b-form-input id="toUser"
type="text"
v-model="selectedTo"
#change="searcher">
</b-form-input>
<div v-on:click="selectToUser(user)" class="userSearchDropDownResult" v-for="user in searchResult" v-if="showSearcherDropdown">{{ user.name }}</div>
Upon clicking a potential match the intention is to set the value of the field and clear away the list of matches.
Here is the code portion of the component:
computed: {
/* method borrowed from Reddit user imGnarly: https://www.reddit.com/r/vuejs/comments/63w65c/client_side_autocomplete_search_with_vuejs/ */
searcher() {
let self = this;
let holder = [];
let rx = new RegExp(this.selectedTo, 'i');
this.users.forEach(function (val, key) {
if (rx.test(val.name) || rx.test(val.email)) {
let obj = {}
obj = val;
holder.push(obj);
} else {
self.searchResult = 'No matches found';
}
})
this.searchResult = holder;
return this.selectedTo;
},
showSearcherDropdown() {
if(this.searchResult == null) return false;
if(this.selectedTo === '') return false;
return true;
}
},
methods: {
selectToUser: function( user ) {
this.newMessage.to = user['.key'];
this.selectedTo = user.name;
this.searchResult = null;
}
}
Typeahead works well, on each change to the input field the searcher() function is called and populates the searchResult with the correct values. The v-for works and a list of divs is shown.
Upon clicking a div, I call selectToUser( user ). This correctly reports details from the user object to the console.
However, on first click I get an exception in the console and the divs don't clear away (I expect them to disappear because I'm setting searchResults to null).
[Vue warn]: Error in event handler for "change": "TypeError: fns.apply is not a function"
found in
---> <BFormInput>
<BFormGroup>
<BTab>
TypeError: fns.apply is not a function
at VueComponent.invoker (vue.esm.js?efeb:2004)
at VueComponent.Vue.$emit (vue.esm.js?efeb:2515)
at VueComponent.onChange (form-input.js?1465:138)
at boundFn (vue.esm.js?efeb:190)
at invoker (vue.esm.js?efeb:2004)
at HTMLInputElement.fn._withTask.fn._withTask (vue.esm.js?efeb:1802)
If I click the div a second time then there's no error, the input value is set and the divs disappear.
So I suspect that writing a value to this.selectedTo (which is also the v-model object for the element is triggering a #change event. On the second click the value of doesn't actually change because it's already set, so no call to searcher() and no error.
I've noticed this also happens if the element loses focus.
Question: how to prevent an #change event when changing v-model value via a method?
(other info: according to package.json I'm on vue 2.5.2)
On:
<b-form-input id="toUser"
type="text"
v-model="selectedTo"
#change="searcher">
The "searcher" should be a method. A method that will be called whenever that b-component issues a change event.
But looking at your code, it is not a method, but a computed:
computed: {
searcher() {
...
},
showSearcherDropdown() {
...
}
},
methods: {
selectToUser: function( user ) {
...
}
}
So when the change event happens, it tries to call something that is not a method (or, in other words, it tries to call a method that doesn't exist). That's why you get the error.
Now, since what you actually want is to update searcher whenever this.selectedTo changes, to get that, it is actually not needed to have that #change handler. This is due to the code of computed: { searcher() { already depending on this.selectedTo. Whenever this.selectedTo changes, Vue will calculate searcher again.
Solution: simply remove #change="searcher" from b-form. Everything else will work.
#acdcjunior, thanks for your answer.
Of course just removing the reference to searcher() just means no action is taken upon field value change so the field won’t work at all.
Moving the searcher() function into methods: {} instead of computed: {} means that it will be called on an input event and not a change even (another mystery but not one for today). A subtle difference that takes away the typeahead feature I’m aiming at.
However, it did make me remember that the result of computed: {} functions are cached and will be re-computed when any parameters change. In this case I realised that the searcher() function is dependent upon the this.selectedTo variable. So when the selectToUser() function sets this.selectedTo it triggers another call to searcher().
Fixed now. In case anyone has a similar problem in the future, I resolved this by turning to old fashioned semaphore by adding another variable.
var userMadeSelection: false
Now, searcher() begins with a check for this scenario:
computed: {
searcher() {
if(this.userMadeSelection) {
this.userMadeSelection = false;
return this.selectedTo;
}
…
and then in selectToUser():
this.userMadeSelection = true;

element not updated when data changes

I have a vaadin-checkbox:
<vaadin-checkbox id=[[item.id]] disabled="true" checked="[[item.checked]]">[[item.description]]</vaadin-checkbox>
I defined my properties:
static get properties() {
return {
items: {
type: Array,
notify: true,
value: function() {
return [];
}
}
};
}
When I now change the data by pressing some button:
_selectItem(event) {
const item = event.model.item;
if (item.checked === true) {
this.$.grid.deselectItem(item);
} else {
this.$.grid.selectItem(item);
}
item.checked = !item.checked;
}
The state of the checkbox is still checked="true". Why isnt the checkbox getting updated? The same thing when I change the description of the item:
_selectItem(event) {
event.model.item.description = 'test';
}
The test description is never appearing. The checkbox is never getting updated.
The reason why the checkbox does not get updated by the button click handler is in the Polymer 2 data system. Polymer does not detect the change and does not update the template accordingly.
In order to make the change in a way that Polymer would detect it you have two options:
Use this.set(`items.${event.model.index}.checked`, !item.checked) if you can reliably assume that the index used by dom-repeat always matches that elements's index in the items array (it is not the case if you use sorting or filtering features of dom-repeat). See an example here https://jsfiddle.net/vlukashov/epd0dn2j/
If you do not know the index of the updated item in the items array, you can also use the Polymer.MutableData mixin and notify Polymer that something has changed inside the items array without specifying the index of the changed item. This is done by calling this.notifyPath('items') after making a change. However, this requires that your element extends the Polymer.MutableData mixin, and that dom-repeat has the mutable-data attribute set. See an example here: https://jsfiddle.net/vlukashov/epd0dn2j/24/
More information on this in the Polymer 2 docs.

Disabling/enabling buttons by data in Vue.js?

I'm having a look at Vue.js with a view to simplifying an application which is starting to get a bit too complex with jQuery. I want to utilise the data binding so that the state of an 'Order' object automatically enables or disables certain buttons (e.g., I want the submit button to be automatically disabled unless an order, containing items, exists). I have something which partly works, with this declaration:
var content = new Vue({
el: '#content',
computed: {
orderExists: function () {
return (shopOrder != null && !isEmpty(shopOrder.items));
}
}
});
I use it in a button like this:
<button type="button" id="btnDisplay" v-bind:disabled="!orderExists">Show Selected</button>
On page load, the buttons using this technique are indeed enabled/disabled correctly. However, when I add items to the order object, thereby changing its state, I'm not seeing any update in the buttons' states - they should be being enabled.
I assume I'm misunderstanding something basic about how this works with Vue.js, as I've only spent a couple of hours with it, so any help would be appreciated.
Hmmm why not pass the property as reactive data?
You can do something like:
...
computed: {
orderExists: function () {
this.disabled = (shopOrder != null && !isEmpty(shopOrder.items));
}
}
Fiddle.
DP: the example is in Vue 2
The issue is that Vue has to manage your data objects in order to set them up in a way where it can observe the changes to them (i.e. be made reactive). This is done by specifying the data option to your Vue creation. Something like:
data: {
shopOrder: null
}
Then update your code like this:
orderExists: function () {
return (this.shopOrder != null && !isEmpty(this.shopOrder.items));
}
And of course at some point you need to set this.shopOrder to a valid order object.