Observing nested properties in Ember controller - checkbox

I'm in the middle of a small project involving Ember. It's my very first time at working with this framework and it has not been an easy learning so far :(
Right now I'm having troubles dealing with nested arrays. What I want to do is pretty standard (at least it seems that way): I have items, item categories and category types (just a way to organize them).
The idea is that there are checkboxes (categories) that allow me to filter the items that are shown in the webpage. On the other hand, there are checkboxes (types) that allow me to check multiple catgories at a time.
In order to implement this I've defined a route (in which I retrieve all the data from these models) and a controller. Originally, I only had items and categories. In this context, I observe the changes in the filters (categories) like this: categories.#each.isChecked and then show the item selection. Unfortunately, now that the hierarchy is types->categories, is not possible to observe changes in categories in the same manner according to the docs:
Note that #each only works one level deep. You cannot use nested forms like todos.#each.owner.name or todos.#each.owner.#each.name.
I google a little bit but didn't find too much about it, so I right now I was thinking in using a custom view for categories (one that extends the Ember.Checkbox) and send an event to the controller whenever a category is checked or unchecked. Is more of a "manual" work and I guess is far from Ember's way of dealing with this type of things.
Is there a standard way of doing this?
Thanks in advance for any help.

One way of solving this would be to observe the category types and filter categories, the same way that the categories are being observed.
This is an example,
http://emberjs.jsbin.com/naqebijebapa/1/edit
(one to many relationships have been assumed)
hbs
<script type="text/x-handlebars" data-template-name="index">
<ul>
{{#each catType in catTypes}}
<li>{{input type="checkbox" checked=catType.isSelected }}{{catType.id}} - {{catType.name}}</li>
{{/each}}
</ul>
<hr/>
<ul>
{{#each cat in filteredCats}}
<li>{{input type="checkbox" checked=cat.isSelected }}{{cat.id}} - {{cat.name}}</li>
{{/each}}
</ul>
<hr/>
<ul>
{{#each item in filteredItems}}
<li>{{item.id}} - {{item.name}}</li>
{{/each}}
</ul>
</script>
js
App = Ember.Application.create();
App.Router.map(function() {
// put your routes here
});
App.CategoryType = Em.Object.extend({
id:null,
name:null,
isSelected:true
});
App.Category = Em.Object.extend({
id:null,
name:null,
type:null,
isSelected:false
});
var catTypeData = [
App.CategoryType.create({id:1,name:"type1"}),
App.CategoryType.create({id:2,name:"type2"}),
App.CategoryType.create({id:3,name:"type3"}),
App.CategoryType.create({id:4,name:"type4"})
];
var catData = [
App.Category.create({id:1,name:"cat1",type:catTypeData[1]}),
App.Category.create({id:2,name:"cat2",type:catTypeData[2]}),
App.Category.create({id:3,name:"cat3",type:catTypeData[0]})
];
var itemsData = [
{id:1,name:"item1",cat:catData[0]},
{id:2,name:"item2",cat:catData[0]},
{id:3,name:"item3",cat:catData[1]}
];
App.IndexRoute = Ember.Route.extend({
model: function() {
return Em.RSVP.hash({catTypes:catTypeData,cats:catData,items:itemsData});
}
});
App.IndexController = Ember.ObjectController.extend({
filteredItems:[],
filterItemsBasedOnCategory:function(){
var selectedCats = this.get("cats").filterBy("isSelected");
if(!Em.isEmpty(selectedCats))
this.set("filteredItems",this.get("items").filter(function(item){
return selectedCats.contains(item.cat);}));
else
this.set("filteredItems",[]);
}.observes("cats.#each.isSelected"),
filterCatsBasedOnCategoryType:function(){
var selectedCatTypes = this.get("catTypes").filterBy("isSelected");
if(!Em.isEmpty(selectedCatTypes))
this.set("filteredCats",this.get("cats").filter(function(cat){
var itContainsIt = selectedCatTypes.contains(cat.type);
if(!itContainsIt){
cat.set("isSelected",false);
}
return itContainsIt;
}));
else
this.set("filteredCats",[]);
}.observes("catTypes.#each.isSelected")
});

Related

AngularJS ng-repeat update does not apply when object keys stay the same?

I'm trying to make a minimal but fancy AngularJS tutorial example, and I am running into an issue where after updating the entire tree for a model (inside the scope of an ng-change update), a template that is driven by a top-level ng-repeat is not re-rendered at all.
However, if I add the code $scope.data = {} at a strategic place, it starts working; but then the display flashes instead of being nice and smooth. And it's not a great example of how AngularJS automatic data binding works.
What am I missing; and what would be the right fix?
Exact code - select a country from the dropdown -
This jsFiddle does not work: http://jsfiddle.net/f9zxt36g/
This jsFiddle works but flickers: http://jsfiddle.net/y090my10/
var app = angular.module('factbook', []);
app.controller('loadfact', function($scope, $http) {
$scope.country = 'europe/uk';
$scope.safe = function safe(name) { // Makes a safe CSS class name
return name.replace(/[_\W]+/g, '_').toLowerCase();
};
$scope.trunc = function trunc(text) { // Truncates text to 500 chars
return (text.length < 500) ? text : text.substr(0, 500) + "...";
};
$scope.update = function() { // Handles country selection
// $scope.data = {}; // uncomment to force rednering; an angular bug?
$http.get('https://rawgit.com/opendatajson/factbook.json/master/' +
$scope.country + '.json').then(function(response) {
$scope.data = response.data;
});
};
$scope.countries = [
{id: 'europe/uk', name: 'UK'},
{id: 'africa/eg', name: 'Egypt'},
{id: 'east-n-southeast-asia/ch', name: 'China'}
];
$scope.update();
});
The template is driven by ng-repeat:
<div ng-app="factbook" ng-controller="loadfact">
<select ng-model="country" ng-change="update()"
ng-options="item.id as item.name for item in countries">
</select>
<div ng-repeat="(heading, section) in data"
ng-init="depth = 1"
ng-include="'recurse.template'"></div>
<!-- A template for nested sections with heading and body parts -->
<script type="text/ng-template" id="recurse.template">
<div ng-if="section.text"
class="level{{depth}} section fact ng-class:safe(heading);">
<div class="level{{depth}} heading factname">{{heading}}</div>
<div class="level{{depth}} body factvalue">{{trunc(section.text)}}</div>
</div>
<div ng-if="!section.text"
class="level{{depth}} section ng-class:safe(heading);">
<div class="level{{depth}} heading">{{heading}}</div>
<div ng-repeat="(heading, body) in section"
ng-init="depth = depth+1; section = body;"
ng-include="'recurse.template'"
class="level{{depth-1}} body"></div>
</div>
</script>
</div>
What am I missing?
You changed reference of section property by executing section = body; inside of ng-if directives $scope. What happened in details (https://docs.angularjs.org/api/ng/directive/ngIf):
ng-repeat on data created $scope for ng-repeat with properties heading and section;
Template from ng-include $compile'd with $scope from 1st step;
According to documentation ng-if created own $scope using inheritance and duplicated heading and section;
ng-repeat inside of template executed section = body and changed reference to which will point section property inside ngIf.$scope;
As section is inherited property, you directed are displaying section property from another $scope, different from initial $scope of parent of ngIf.
This is easily traced - just add:
...
<script type="text/ng-template" id="recurse.template">
{{section.Background.text}}
...
and you will notice that section.Background.text actually appoints to proper value and changed accordingly while section.text under ngIf.$scope is not changed ever.
Whatever you update $scope.data reference, ng-if does not cares as it's own section still referencing to previous object that was not cleared by garbage collector.
Reccomdendation:
Do not use recursion in templates. Serialize your response and create flat object that will be displayed without need of recursion. As your template desired to display static titles and dynamic texts. That's why you have lagging rendering - you did not used one-way-binding for such static things like section titles. Some performance tips.
P.S. Just do recursion not in template but at business logic place when you manage your data. ECMAScript is very sensitive to references and best practice is to keep templates simple - no assignments, no mutating, no business logic in templates. Also Angular goes wild with $watcher's when you updating every of your section so many times without end.
Thanks to Apperion and anoop for their analysis. I have narrowed down the problem, and the upshot is that there seems to be a buggy interaction between ng-repeat and ng-init which prevents updates from being applied when a repeated variable is copied in ng-init. Here is a minimized example that shows the problem without using any recursion or includes or shadowing. https://jsfiddle.net/7sqk02m6/
<div ng-app="app" ng-controller="c">
<select ng-model="choice" ng-change="update()">
<option value="">Choose X or Y</option>
<option value="X">X</option>
<option value="Y">Y</option>
</select>
<div ng-repeat="(key, val) in data" ng-init="copy = val">
<span>{{key}}:</span> <span>val is {{val}}</span> <span>copy is {{copy}}</span>
</div>
</div>
The controller code just switches the data between "X" and "Y" and empty versions:
var app = angular.module('app', []);
app.controller('c', function($scope) {
$scope.choice = '';
$scope.update = function() {
$scope.data = {
X: { first: 'X1', second: 'X2' },
Y: { first: 'Y1', second: 'Y2' },
"": {}
}[$scope.choice];
};
$scope.update();
});
Notice that {{copy}} and {{val}} should behave the same inside the loop, because copy is just a copy of val. They are just strings like 'X1'. And indeed, the first time you select 'X', it works great - the copies are made, they follow the looping variable and change values through the loop. The val and the copy are the same.
first: val is X1 copy is X1
second: val is X2 copy is X2
But when you update to the 'Y' version of the data, the {{val}} variables update to the Y version but the {{copy}} values do not update: they stay as X versions.
first: val is Y1 copy is X1
second: val is Y2 copy is X2
Similarly, if you clear everything and start with 'Y', then update to 'X', the copies get stuck as the Y versions.
The upshot is: ng-init seems to fail to set up watchers correctly somehow when looped variables are copied in this situation. I could not follow Angular internals well enough to understand where the bug is. But avoiding ng-init solves the problem. A version of the original example that works well with no flicker is here: http://jsfiddle.net/cjtuyw5q/
If you want to control what keys are being tracked by ng-repeat you can use a trackby statement: https://docs.angularjs.org/api/ng/directive/ngRepeat
<div ng-repeat="model in collection track by model.id">
{{model.name}}
</div>
modifying other properties won't fire the refresh, which can be very positive for performance, or painful if you do a search/filter across all the properties of an object.

Exclude selected options from multiple dropdowns in angularjs?

I have five dropdowns. All five dropdowns have same source for options. An option selected in one dropdown should not appear in options of remaining dropdowns. What is best way to achieve it in angularjs?
And source for ng-options is array of objects.
You'll need to come up with an implementation that suits your specific needs and desired user experience, but this may help you work out some basic principles you can use. This is just the most minimal example I could think of. The main point is the custom filter that you'll filter ng-options down with. It will receive your array of objects (the options), and you'll also pass in the other models so that you can filter them out if they're selected.
Here, I'm looping over each selected object and removing it from the ng-options array, unless it's the object selected on this element.
angular.module('myApp', [])
.filter('customFilter', function(filterFilter) {
return function(input, filterEach, exclude) {
filterEach.forEach(function(item) {
if (angular.equals(item, exclude)) { return; }
input = filterFilter(input, '!'+item);
});
return input;
};
});
<script src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/3.4.0/lodash.min.js"></script>
<script src="//ajax.googleapis.com/ajax/libs/angularjs/1.3.2/angular.min.js"></script>
<div ng-app="myApp" ng-init="foos = [{label:'label 1',id:1},{label:'label 2',id:2},{label:'label 3',id:3}]; selected = []">
<div ng-repeat="foo in foos">
<select ng-model="selected[$index]" ng-options="obj.id as obj.label for obj in foos | customFilter:selected:selected[$index]"></select>
</div>
</div>

ng-repeat doesn't seem to be working

I am trying to pull all items from my array called 'collections'. When I input the call in my html I see only the complete code for the first item in the array on my page. I am trying to only pull in the 'edm.preview' which is the image of the item. I am using Angular Firebase and an api called Europeana. My app allows the user to search and pick which images they like and save them to that specific user.
here is the js:
$scope.users = fbutil.syncArray('users');
$scope.users.currentUser.collections = fbutil.syncArray('collections');
$scope.addSomething = function (something) {
var ref = fbutil.ref('users');
ref.child($rootScope.currentUser.uid).child('collections').push(something);
}
$scope.addSomething = function(item) {
if( newColItem ) {
// push a message to the end of the array
$scope.collections.$add(newColItem)
// display any errors
.catch(alert);
}
};
and the html:
<ul id="collections" ng-repeat="item in collections">
<li ng-repeat="item in collections">{{item.edmPreview}}</li>
</ul>
First, remove the outer ng-repeat. You want to only add the ng-repeat directive to the element which is being repeated, in this case <li>.
Second, from your AngularJS code, it looks like you want to loop over users.currentUser.collections and not just collections:
<ul id="collections">
<li ng-repeat="item in users.currentUser.collections">{{item.edmPreview}}</li>
</ul>
And third, you're defining the function $scope.addSomething twice in your JavaScript code. Right now, the second function definition (which, incidentally, should be changed to update $scope.users.currentUser.collections as well) will completely replace the first.

How to get a single item from a GoInstant collection?

How do you get a single item from a GoInstant GoAngular collection? I am trying to create a typical show or edit screen for a single task, but I cannot get any of the task's data to appear.
Here is my AngularJS controller:
.controller('TaskCtrl', function($scope, $stateParams, $goKey) {
$scope.tasks = $goKey('tasks').$sync();
$scope.tasks.$on('ready', function() {
$scope.task = $scope.tasks.$key($stateParams.taskId);
//$scope.task = $scope.tasks.$key('id-146b1c09a84-000-0'); //I tried this too
});
});
And here is the corresponding AngularJS template:
<div class="card">
<ul class="table-view">
<li class="table-view-cell"><h4>{{ task.name }}</h4></li>
</ul>
</div>
Nothing is rendered with {{ task.name }} or by referencing any of the task's properties. Any help will be greatly appreciated.
You might handle these tasks: (a) retrieving a single item from a collection, and (b) responding to a users direction to change application state differently.
Keep in mind, that a GoAngular model (returned by $sync()) is an object, which in the case of a collection of todos might look something like this:
{
"id-146ce1c6c9e-000-0": { "description": "Destroy the Death Start" },
"id-146ce1c6c9e-000-0": { "description": "Defeat the Emperor" }
}
It will of course, have a number of methods too, those can be easily stripped using the $omit method.
If we wanted to retrieve a single item from a collection that had already been synced, we might do it like this (plunkr):
$scope.todos.$sync();
$scope.todos.$on('ready', function() {
var firstKey = (function (obj) {
for (var firstKey in obj) return firstKey;
})($scope.todos.$omit());
$scope.firstTodo = $scope.todos[firstKey].description;
});
In this example, we synchronize the collection, and once it's ready retrieve the key for the first item in the collection, and assign a reference to that item to $scope.firstTodo.
If we are responding to a users input, we'll need the ID to be passed from the view based on a user's interaction, back to the controller. First we'll update our view:
<li ng-repeat="(id, todo) in todos">
{{ todo.description }}
</li>
Now we know which todo the user want's us to modify, we describe that behavior in our controller:
$scope.todos.$sync();
$scope.whichTask = function(todoId) {
console.log('this one:', $scope.todos[todoId]);
// Remove for fun
$scope.todos.$key(todoId).$remove();
}
Here's a working example: plunkr. Hope this helps :)

Ember ArrayController binding to view

I've snipped out the init function which sets up the initials array.
This is an array of arays indexed as "A", "B", "C" etc.
Each of these contains station object that begin with that letter.
I have buttons that fire off setByInitial which copy the relevant initial array into content.
this.content.setObjects(this.initials[initial])
works fine and my view updates, but is horribly slow (150ms +) station objects are pertty big and there are over 3500 of them...
this.set("content",Ember.copy(this.initials[initial],true))
Is much fatser (around 3ms) updated the content aray (as can be seen with some logging to console), but does not cause the view to update.
this.set("content",this.initials[initial])
is even faster, but also does not update the view.
I've tried using arrayContentDidChange() etc. but can't get that to work either.
How do I inform the view that this dfata has changed if I use the faster method? Or is there another wau to do this?
App.StationListController = Ember.ObjectController.extend({
content : [],
initials : [],
setByInitial : function(initial)
{
// this.content.setObjects(this.initials[initial])
this.set("content",Ember.copy(this.initials[initial],true))
}
});
<script type="text/x-handlebars" id="stationList">
<ul>
{{#each content}}
<li>{{#linkTo "station" u}}{{n}}{{/linkTo}}</li>
{{/each}}
</ul>
</script>
Thanks to #mike-grassotti example I can see that what I was doing ought to work, but it still doesn't! As is often the case, what I have posted here is a simplification. My real app is not so straight forward...
My index template contain several views. Each view has it's own data and controller. So it seems it's something in that complexity which is breaking it. So, I've started with Mike's example and added just a little - in order to move towards what I really want - and promptly broken it!
I now have:
var App
= Ember.Application.create({})
App.Router.map(function() {
this.resource('index', {path: '/'});
this.resource('station', {path: '/:code/:name'});
this.resource('toc', {path: '/toc/:code/:name'});
});
App.Station = Ember.Object.extend({});
App.IndexController = Ember.ObjectController.extend({
needs : ["StationList"],
listStationsByInitial: function(initial)
{
this.get("controllers.StationList").listByInitial(initial)
}
});
App.StationListView = Em.View.extend({
stationsBinding : 'App.StationListController',
init : function()
{
console.log("view created",this.stations)
}
});
App.StationListController = Ember.ArrayController.extend({
content : [],
initials : [[{u:1,n:'Abe'},{u:2,n:'Ace'}],[{u:3, n:'Barb'},{u:4,n:'Bob'}],[{u:5,n:'Card'},{u:6,n:'Crud'}]],
init : function()
{
this.set("content",this.initials[0])
},
listByInitial : function(initial)
{
this.set("content",this.initials[initial])
console.log('StationListController',this.content);
}
});
and
t type="text/x-handlebars" data-template-name="application">
{{outlet}}
</script>
<script type="text/x-handlebars" data-template-name="index">
<button {{action listStationsByInitial '0'}}>A</button>
<button {{action listStationsByInitial '1'}}>B</button>
{{#view App.StationListView controllerBinding="App.StationListController"}}
<ul>
{{#each stations}}
<li>{{#linkTo "station" u}}{{n}}{{/linkTo}}</li>
{{else}}
<li>No matching stations</li>
{{/each}}
</ul>
<button {{action listByInitial '2'}}>C</button>
{{/view}}}
</script>
Firstly, I no longer see the list of station rendered. Neither initially, nor on the click of a button.
I expected {{#with content}} to get the data from App.StationListController.content, but that didn't work. So, I created App.StationListView with a binding stationsBinding to that controller. Still no joy...
What am I doing wrong here?
Secondly, my function listStationsByInitial is called when I click button A or B. So I'd expect listByInitial (in StationListController) to be called when I click button C (since it's inside of the view where I've said to use StationListController). But instead I get an error:
error: assertion failed: The action 'listByInitial' did not exist on App.StationListController
Why doesn't that work?
I'm doubly frustrated here because I have already build a pretty large and complex Ember app (http://rail.dev.hazardousfrog.com/train-tickets) using 1.0.pre version and am now trying to bring my konwledge up-to-date with the latest version and finding that almost nothing I learned applies any more!
How do I inform the view that this dfata has changed if I use the faster method?
You should not have to inform the view, this is taken care of via bindings. I can't see anything in your example that would prevent bindings from updating automatically, and made a simple jsFiddle to demonstrate. Given the following, the list of stations is modified when user hits one of the buttons and view updates automatically:
App = Ember.Application.create({});
App.Router.map( function() {
this.route('stationList', {path: '/'});
this.route('station', {path: '/station/:station_id'});
});
App.Station = Ember.Object.extend({});
App.StationListController = Ember.ObjectController.extend({
content : [],
initials : [
[{u:1,n:'Abe'}, {u:2,n:'Ace'}], [{u:1,n:'Barb'}, {u:2,n:'Bob'}]
],
setByInitial : function(initial)
{
console.log('setByInitial', initial);
this.set("content",this.initials[initial])
}
});
<script type="text/x-handlebars" id="stationList">
<h2>Stations:</h2>
<button {{action setByInitial '0'}}>A</button>
<button {{action setByInitial '1'}}>B</button>
<ul>
{{#each content}}
<li>{{#linkTo "station" u}}{{n}}{{/linkTo}}</li>
{{/each}}
</ul>
</script>
See: http://jsfiddle.net/mgrassotti/7f4w7/1/

Resources