Vue.js - Detect Nested Property Value Changes - javascript

I'm dynamically binding to an Array of objects in a Vue.js 2.0 app. I want to respond to changes as values in that Array change. At this time, as shown in this Fiddle, I have the following:
html
<div id="app">
<table>
<thead>
<tr>
<th v-for="(col, index) in cols">
<input :placeholder="col.title" v-model="inputValues[col.prop]" />
</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, index) in items">
<td>{{ item.name }}</td>
<td>{{ item.age }}</td>
<td>{{ item.birthday }}</td>
</tr>
</tbody>
</table>
<hr />
<textarea>{{ inputValues }}</textarea>
</div>
javascript
new Vue({
el: '#app',
data: {
cols: [
{ title: 'Name', prop:'name' },
{ title: 'Age', prop:'age' },
{ title: 'Birthday', prop:'birthday' },
],
inputValues: [],
items: [
{ id:1, name:'Andreas Winchell', age:47, birthday:'08-04-1970' },
{ id:2, name:'Victoria Hodges', age:80, birthday:'01-24-1937' },
{ id:2, name:'James Morris', age:59, birthday:'06-14-1958' },
{ id:2, name:'Larry Walker', age:68, birthday:'08-07-1949' },
{ id:2, name:'Lee Maynard', age:46, birthday:'04-17-1971' }
]
},
methods: {
buttonClick: function() {
alert(JSON.stringify(this.inputValues));
}
}
})
I can't seem to find a way to bind to changes in any of the values entered into the header. How do I detect and react to property values changes in an Array?

Update 2022
Vue 3 will detect all changes in a reactive array. Square bracket syntax is no longer a limitation.
You can use arrays in your data, but Vue will not detect direct changes to the array referencing an item by its index. Vue will detect changes made with push(), pop(), slice() etc.
Arrays like 'Cols' are a disaster. 'Name', 'Age' and 'Birthday' are code (property names), not data. You really don't want to be iterating over an array generating forms like this. Keep it simple and code your three inputs.

inputValues should be an object, not an array.
data: {
cols: [
{ title: 'Name', prop:'name' },
{ title: 'Age', prop:'age' },
{ title: 'Birthday', prop:'birthday' },
],
// inputValues should be an object.
inputValues: {name: null, age: null, birthday: null },
items: [
{ id:1, name:'Andreas Winchell', age:47, birthday:'08-04-1970' },
{ id:2, name:'Victoria Hodges', age:80, birthday:'01-24-1937' },
{ id:2, name:'James Morris', age:59, birthday:'06-14-1958' },
{ id:2, name:'Larry Walker', age:68, birthday:'08-07-1949' },
{ id:2, name:'Lee Maynard', age:46, birthday:'04-17-1971' }
]
}

As stated here in the VueJS documentation.
Due to limitations in JavaScript, Vue cannot detect the following changes to an array:
When you directly set an item with the index, e.g.
vm.items[indexOfItem] = newValue
To overcome this, both of the following will accomplish the same as vm.items[indexOfItem] = newValue, but will also trigger state updates in the reactivity system:
// Vue.set
Vue.set(vm.items, indexOfItem, newValue)
// Array.prototype.splice
vm.items.splice(indexOfItem, 1, newValue)

Related

How to $set a property to multiple objects in an array but have retain individual reactivity in vue js

In my case, I have data array with multiple objects
data() {
return {
selected: 0,
presetData: [true, true, true],
data: [
{
name: "name 1"
},
{
name: "name 2"
}
]
};
},
then I want to push inside each object in data like below
setNewData() {
this.data.forEach((o, i) => {
this.$set(this.data[i], "time", this.presetData);
});
},
now my with presetData pushed into data will look like this
data: [
{
name: "name 1",
time: [true, true, true]
},
{
name: "name 2",
time: [true, true, true]
}
]
and I want to change individual time property of each object, which I use something like below
$set(item.time,selected,true)
My Issue
my issue is, this going to change both objects time property. How do I first push/set correctly presetData to data, below is my entire code , I'm sorry I'm very new to programming, here is the link to jsfiddle
new Vue({
el: "#app",
data() {
return {
selected: 0,
presetData: [true, true, true],
data: [
{
name: "name 1",
},
{
name: "name 2",
}
]
};
},
methods: {
setNewData() {
this.data.forEach((o, i) => {
this.$set(this.data[i], "time", this.presetData);
});
},
}
})
<div id="app">
<button #click="setNewData">Set Data</button>
<br>
<br>
<select v-model="selected">
<option>0</option>
<option>1</option>
<option>2</option>
</select>
<div v-for="item in data" :key="item.id">
<p>{{item.name}}</p>
<p>{{item.time}}</p>
<button #click="$set(item.time,selected,true)">Change True</button>
<button #click="$set(item.time,selected,false)">Change False</button>
</div>
This is an object reference issue. Each of your time properties references the same array (presetData). You can break out of this problem by making shallow copies via spread syntax.
You can also avoid Vue.set() when assigning new data using the same technique
setNewData() {
this.data = this.data.map(d => ({
...d, // create a shallow copy of each data item
time: [...this.presetData] // add "time" as a shallow copy of presetData
}))
},
To change individual array elements within the time property, you need to continue using Vue.set(), ie
this.$set(item.time, selected, true)

Vue v-for loop - How To Target Component When Array is Filtered

I am using a computed value to dynamically filter an array ("orders").
The computed .filter() function allows the user to dynamically search by order number, name or reference:
data() {
return {
orders: [],
search: "" // search string from a text input
};
},
computed: {
filtered:
return this.orders.filter(order => {
const s =
order.order_number + order.reference + order.name;
const su = s.toUpperCase();
return su.match(this.search.toUpperCase());
});
}
I am using a v-for loop to render the search results as follows:
<tbody v-for="(order, index) in filtered" :key="order.id">
<tr>
<td #click="add_events(order, index)>{{order.order_number}}</td>
<td>{{order.reference}}</td>
<td>{{order.name}}</td>
...
</tr>
</tbody>
I want to use the #click to target a specific component (an object) in the filtered array and use $set to append a value ("objEvents") to that object:
methods: {
add_events (order, index) {
const objEvents= [ external data from an API ]
this.$set(this.orders[index], "events", objEvents)
}
}
However the index of the component in the filtered array ("filtered") is not the same as its index in the original array ("orders") and so the add_events method targets the wrong component.
Can I use key to target the correct component? or is there some other way to identify the target component in the filtered array?
There's no need to track index. filtered is just an array of references to the original objects in orders, so you could modify the order iterator in add_events() to achieve the desired effect:
this.$set(order, 'events', objEvents);
new Vue({
el: '#app',
data() {
return {
orders: [
{id: 1, order_number: 111, name: 'John', reference: 'R111'},
{id: 2, order_number: 222, name: 'Bob', reference: 'R222'},
{id: 3, order_number: 333, name: 'Bob', reference: 'R333'},
],
search: ''
};
},
computed: {
filtered() {
return this.orders.filter(order => {
const s =
order.order_number + order.reference + order.name;
const su = s.toUpperCase();
return su.match(this.search.toUpperCase());
});
}
},
methods: {
add_events(order, index) {
const objEvents = [
{id: 1, name: 'Event 1'},
{id: 2, name: 'Event 2'},
{id: 3, name: 'Event 3'}
];
this.$set(order, "events", objEvents);
}
}
})
<script src="https://unpkg.com/vue#2.5.17"></script>
<div id="app">
<input type="text" v-model="search" placeholder="Search">
<table>
<tbody v-for="(order, index) in filtered" :key="order.id">
<tr>
<td #click="add_events(order, index)">{{order.order_number}}</td>
<td>{{order.reference}}</td>
<td>{{order.name}}</td>
<td>{{order.events}}</td>
</tr>
</tbody>
</table>
<pre>{{orders}}</pre>
</div>
You could map the original array and add the an origIndex property to each item as follows :
computed:{
filtered(){
let mapped= this.orders.map((item,i)=>{
let tmp=item;
tmp.origIndex=i;
return tmp;
});
return this.mapped.filter(order => {
const s =
order.order_number + order.reference + order.name;
const su = s.toUpperCase();
return su.match(this.search.toUpperCase());
});
}
}//end computed
In your template use the origIndex property instead of index
<tbody v-for="(order, index) in filtered" :key="order.id">
<tr>
<td #click="add_events(order, order.origIndex)>{{order.order_number}}</td>
<td>{{order.reference}}</td>
<td>{{order.name}}</td>
...
</tr>
</tbody>

bind multidimensional array with vuejs

I am building a dynamic table on my front end side, and at the end i need to know what was inserted on each cell of my table since it is editable, so i did this on my html:
<table class="table table-responsive">
<tbody>
<tr v-for="(row,idx1) in tableRows" :class="{headerRowDefault: checkHeader(idx1)}">
<td class="table-success" v-for="(col,idx2) in tableCols"><input v-model="items[idx1][idx2]" type="text" class="borderTbl" value="HEY"/></td>
</tr>
</tbody>
</table>
as you guys can see. i set inside the input a v-model with items[idx1][idx2] so i can pass the value to that line and columns, it is not working like this, i don't know how to set it.
This is my javascript:
export default {
name: 'app',
data () {
return {
table: {
rows: 1,
cols: 1,
key: 'Table',
tableStyle: 1,
caption: '',
colx: []
},
hasHeader: true,
hasCaption: true,
insert: 1,
idx2: 1,
items: []
}
},
computed: {
tableStyles () {
return this.$store.getters.getTableStyles
},
tableRows () {
return parseInt(this.table.rows)
},
tableCols () {
return parseInt(this.table.cols)
}
expected items array:
items:[
["john","Micheal"]
["john","Micheal"]
["john","Micheal"]
["john","Micheal"]
]
So, I think you're not pointing your models correctly.
Template:
<tr v-for="(row, idx1) in items">
<td class="table-success" v-for="(col, idx2) in row">
<input v-model="items[idx1][idx2]" type="text" />
</td>
</tr>
Script:
data () {
return {
items:[
["john","Micheal"],
["john","Micheal"],
["john","Micheal"],
["john","Micheal"]
];
};
}
Here's a working fiddle of it

Vue JS use array to filterBy another array?

Yep, it's me again. I'm trying to filter an array based on an array of strings. So while a single string filter is easy with Vue...
<div v-for="item in items | filterBy 'words' in 'property'">
...multiple search strings becomes more complex. There's been several questions about how to do this on StackOverflow already, but very few answers. Currently I'm trying to repurpose the custom filter found here for my needs, but it's not working.
My use case:
I have an array of groups (checkboxes) that the user can select to filter an array of people. Each person is assigned to one or more groups so if any one of their groups is selected by the user, that person should show up.
So my templates look like this:
<template v-for="group in ensemble_groups">
<input name="select_group[]" id="group_#{{ $index }}"
:value="group"
v-model="selected_groups"
type="checkbox">
<label for="group_#{{ $index }}">#{{ group }}</label>
</template>
<template v-for="person in cast | filterBy selectGroups">
<div>#{{ person.actor_name }}</div>
</template>
You see my custom filter selectGroups there? Here's my Vue arrays:
selected_groups: [],
ensemble_groups: ["Leads","Understudies","Children","Ensemble"],
cast: [
{
actor_name: "Dave",
groups: ["Leads"],
},
{
actor_name: "Jill",
groups: ["Leads"],
},
{
actor_name: "Sam",
groups: ["Children","Ensemble"],
},
{
actor_name: "Austin",
groups: ["Understudies","Ensemble"],
},
],
And finally here's the custom filter. I can't tell if it's even being triggered or not, because when I click on a group checkbox, nothing happens.
filters: {
selectGroups: function() {
if (!selected_groups || selected_groups.length === 0) {
return cast;
}
return this.recursiveFilter(cast, selected_groups, 0);
}
},
methods: {
recursiveFilter: function(cast, selected_groups, currentPosition) {
if (currentPosition+1 > selected_groups.length)
return cast;
var new_cast;
new_cast = cast.filter(function(person) {
for (group of person.groups) {
if (group.value == selected_groups[currentPosition])
return true;
}
});
return this.recursiveFilter(new_cast, selected_groups, currentPosition+1);
}
}
So if the user selects Leads only Dave and Jill should appear. If the user then checks Children, Dave, Jill, and Sam should appear. I'm so close!
I would use a computed property instead of a filter and a method.
I'd go through each cast member and if any of their groups is in selected_groups I'd allow it through the filter. I'd so this using Array.some.
results: function() {
var self = this
return self.cast.filter(function(person) {
return person.groups.some(function(group) {
return self.selected_groups.indexOf(group) !== 1
})
})
},
Here's a quick demo I set up, might be useful: http://jsfiddle.net/crswll/df4Lnuw6/8/
Since filters are deprecated (in v-for, see Bill's comment), you should get into the habit of making computeds to do filtery things.
(If you're on IE, you can't use includes without a polyfill; you can use indexOf...>=0 instead.)
new Vue({
el: '#app',
data: {
selected_groups: [],
ensemble_groups: ["Leads", "Understudies", "Children", "Ensemble"],
cast: [{
actor_name: "Dave",
groups: ["Leads"],
}, {
actor_name: "Jill",
groups: ["Leads"],
}, {
actor_name: "Sam",
groups: ["Children", "Ensemble"],
}, {
actor_name: "Austin",
groups: ["Understudies", "Ensemble"],
}, ]
},
computed: {
filteredCast: function() {
const result = [];
for (const c of this.cast) {
if (this.anyMatch(c.groups, this.selected_groups)) {
result.push(c);
}
}
return result;
}
},
methods: {
anyMatch: function(g1, g2) {
for (const g of g1) {
if (g2.includes(g)) {
return true;
}
}
return false;
}
}
});
<script src="//cdnjs.cloudflare.com/ajax/libs/vue/2.0.3/vue.min.js"></script>
<div id="app">
<template v-for="group in ensemble_groups">
<input name="select_group[]" id="group_#{{ $index }}" :value="group" v-model="selected_groups" type="checkbox">
<label for="group_#{{ $index }}">#{{ group }}</label>
</template>
<template v-for="person in filteredCast">
<div>#{{ person.actor_name }}</div>
</template>
</div>
var demo = new Vue({
el: '#demo',
data: {
search: 're',
people: [
{name: 'Koos', age: 30, eyes:'red'},
{name: 'Gert', age: 20, eyes:'blue'},
{name: 'Pieter', age: 12, eyes:'green'},
{name: 'Dawid', age: 67, eyes:'dark green'},
{name: 'Johan', age: 15, eyes:'purple'},
{name: 'Hans', age: 12, eyes:'pink'}
]
},
methods: {
customFilter: function(person) {
return person.name.indexOf(this.search) != -1
|| person.eyes.indexOf(this.search) != -1
;
}
},
})
<script src="https://cdnjs.cloudflare.com/ajax/libs/vue/1.0.26/vue.min.js"></script>
<div id="demo">
<input type="text" class="form-control" v-model="search"/>
<br/>
<table class="table">
<thead>
<tr>
<th>name</th>
<th>eyes</th>
<th>age</th>
</tr>
</thead>
<tr v-for="person in people | filterBy customFilter">
<td>{{ person.name }}</td>
<td>{{ person.eyes }}</td>
<td>{{ person.age }}</td>
</tr>
</table>
</div>

Angular select all checkboxes from outside ng-repeat

Description
I have a small product order system, where a user can add order lines, and on each order line add one or more products. (I realise it's quite unusual for more than one product to be on the same order line, but that's another issue).
The products that can be selected on each line is based on a hierarchy of products. For example:
Example product display
T-Shirts
V-neck
Round-neck
String vest
JSON data
$scope.products = [
{
id: 1,
name: 'T Shirts',
children: [
{ id: 4, name: 'Round-neck', children: [] },
{ id: 5, name: 'V-neck', children: [] },
{ id: 6, name: 'String vest (exclude)', children: [] }
]
},
{
id: 2,
name: 'Jackets',
children: [
{ id: 7, name: 'Denim jacket', children: [] },
{ id: 8, name: 'Glitter jacket', children: [] }
]
},
{
id: 3,
name: 'Shoes',
children: [
{ id: 9, name: 'Oxfords', children: [] },
{ id: 10, name: 'Brogues', children: [] },
{ id: 11, name: 'Trainers (exclude)', children: []}
]
}
];
T-Shirts isn't selectable, but the 3 child products are.
What I'm trying to achieve
What I'd like to be able to do, is have a 'select all' button which automatically adds the three products to the order line.
A secondary requirement, is that when the 'select all' button is pressed, it excludes certain products based on the ID of the product. I've created an 'exclusion' array for this.
I've set up a Plunker to illustrate the shopping cart, and what I'm trying to do.
So far it can:
Add / remove order lines
Add / remove products
Add a 'check' for all products in a section, excluding any that are in the 'exclusions' array
The problem
However, although it adds the check in the input, it doesn't trigger the ng-change on the input:
<table class="striped table">
<thead>
<tr>
<td class="col-md-3"></td>
<td class="col-md-6"></td>
<td class="col-md-3"><a ng-click="addLine()" class="btn btn-success">+ Add order line</a></td>
</tr>
</thead>
<tbody>
<tr ng-repeat="line in orderHeader.lines">
<td class="col-md-3">
<ul>
<li ng-repeat="product in products" id="line_{{ line.no }}_product_{{ product.id }}">
{{ product.name }} <a ng-click="selectAll(product.id, line.no)" class="btn btn-primary">Select all</a>
<ul>
<li ng-repeat="child in product.children">
<input type="checkbox"
ng-change="sync(bool, child, line)"
ng-model="bool"
data-category="{{child.id}}"
id="check_{{ line.no }}_product_{{ child.id }}"
ng-checked="isChecked(child.id, line)">
{{ child.name }}
</li>
</ul>
</li>
</ul>
</td>
<td class="col-md-6">
<pre style="max-width: 400px">{{ line }}</pre>
</td>
<td class="col-md-3">
<a ng-click="removeLine(line)" class="btn btn-warning">Remove line</a>
</td>
</tr>
</tbody>
</table>
Javascript
$scope.selectAll = function(product_id, line){
target = document.getElementById('line_'+line+'_product_'+product_id);
checkboxes = target.getElementsByTagName('input');
for (var i = 0; i < checkboxes.length; i++) {
if (checkboxes[i].type == 'checkbox') {
category = checkboxes[i].dataset.category;
if($scope.excluded.indexOf(parseInt(category)) == -1)
{
checkboxes[i].checked = true;
// TODO: Check the checkbox, and set its bool parameter to TRUE
}
}
}
}
Update with full solution
There were a couple of issues with the above code. Firstly, I was trying to solve the problem by manipulating the DOM which is very much against what Angular tries to achieve.
So the solution was to add a 'checked' property on the products so that I can track if they are contained on the order line, and then the view is updated automatically.
One drawback of this method is that the payload would be significantly larger (unless it is filtered before being sent to the back-end API) as each order line now has data for ALL products, even if they aren't selected.
Also, one point that tripped me up was forgetting that Javascript passes references of objects / arrays, not a new copy.
The solution
Javascript
var myApp = angular.module('myApp', []);
myApp.controller('CartForm', ['$scope', function($scope) {
var inventory = [
{
id: 1,
name: 'T Shirts',
checked: false,
children: [
{ id: 4, name: 'Round-neck', checked: false, children: [] },
{ id: 5, name: 'V-neck', checked: false, children: [] },
{ id: 6, name: 'String vest (exclude)', checked: false, children: [] }
]
},
{
id: 2,
name: 'Jackets',
checked: false,
children: [
{ id: 7, name: 'Denim jacket', checked: false, children: [] },
{ id: 8, name: 'Glitter jacket', checked: false, children: [] }
]
},
{
id: 3,
name: 'Shoes',
checked: false,
children: [
{ id: 9, name: 'Oxfords', checked: false, children: [] },
{ id: 10, name: 'Brogues', checked: false, children: [] },
{ id: 11, name: 'Trainers (exclude)', checked: false, children: []}
]
}
];
$scope.debug_mode = false;
var products = angular.copy(inventory);
$scope.orderHeader = {
order_no: 1,
total: 0,
lines: [
{
no: 1,
products: products,
total: 0,
quantity: 0
}
]
};
$scope.excluded = [6, 11];
$scope.addLine = function() {
var products = angular.copy(inventory);
$scope.orderHeader.lines.push({
no: $scope.orderHeader.lines.length + 1,
products: products,
quantity: 1,
total: 0
});
$scope.loading = false;
}
$scope.removeLine = function(index) {
$scope.orderHeader.lines.splice(index, 1);
}
$scope.selectAll = function(product){
angular.forEach(product.children, function(item){
if($scope.excluded.indexOf(parseInt(item.id)) == -1) {
item.checked=true;
}
});
}
$scope.removeAll = function(product){
angular.forEach(product.children, function(item){
item.checked=false;
});
}
$scope.toggleDebugMode = function(){
$scope.debug_mode = ($scope.debug_mode ? false : true);
}
}]);
Click here to see the Plunker
You are really over complicating things first by not taking advantage of passing objects and arrays into your controller functions and also by using the DOM and not your data models to try to update states
Consider this simplification that adds a checked property to each product via ng-model
<!-- checkboxes -->
<li ng-repeat="child in product.children">
<input ng-model="child.checked" >
</li>
If it's not practical to add properties to the items themselves, you can always keep another array for the checked properties that would have matching indexes with the child arrays. Use $index in ng-repeat for that
And passing whole objects into selectAll()
<a ng-click="selectAll(product,line)">
Which allows in controller to do:
$scope.selectAll = function(product, line){
angular.forEach(product.children, function(item){
item.checked=true;
});
line.products=product.children;
}
With angular you need to always think of manipulating your data models first, and let angular manage the DOM
Strongly suggest reading : "Thinking in AngularJS" if I have a jQuery background?
DEMO
Why ng-change isn't fired when the checkbox is checked programatically?
It happens because
if($scope.excluded.indexOf(parseInt(category)) == -1)
{
checkboxes[i].checked = true;
// TODO: Check the checkbox, and set its bool parameter to TRUE
}
only affects the view (DOM). ng-change works alongside ngModel, which can't be aware that the checkbox really changed visually.
I suggest you to refer to the solution I provided at How can I get angular.js checkboxes with select/unselect all functionality and indeterminate values?, works with any model structure you have (some may call this the Angular way).

Categories