ScrollSpy directive that talks to controller in AngularJS - javascript

I'm new to AngularJS and already on the brink of despair :) My app looks like this (Not-working example on Plnkr):
HTML
<div ng-app="myApp" ng-controller='controllers.content' >
<h2 id="fixed">{{ header }}</h2>
<ul>
<li ng-repeat="item in db" scroll-stop="item">{{ item.name }}</li>
</ul>
</div>
JS
angular.module('myApp.controllers',[]);
var app = angular.module('myApp', [
'myApp.controllers'
]);
var app = angular.module('myApp.controllers').controller('controllers.content', ['$scope', function($scope){
$scope.db = [
{ name: "20 February 2014", header: "Today" },
// ...
];
$scope.header = "Today";
}]);
Now basically I want a scroll spy that changes the contents of the h2 (assume it's fixed to the top) to the header property of the item in db whenever that item is currently at the top of the viewport:
app.directive('scrollStop', function() {
return {
restrict: 'A',
scope: {
scrollStop:'='
},
link: function (scope, element, attrs) {
var $page = angular.element(window)
var $el = element[0]
var last_top = $el.getBoundingClientRect().top - 60;
$page.bind('scroll', function () {
var current_top = $el.getBoundingClientRect().top - 60;
if (last_top >= 0 && current_top < 0) {
// HERE!
console.log(scope.scrollStop.header);
scope.header = scope.scrollStop.header;
}
last_top = current_top;
})
}
}
});
However, I can't figure out how to talk to the content controller from within the directive to edit $scope.header from within the directive. I understand that the scope is a different to the controller's $scope, and have a vague feeling that I should use require somewhere, but can't figure out how to get it right. Partly also because there seem to be 5 different shorthands for doing anything in AngularJS and they don't always go well together. Any help appreciated, including stylistic guidance.

You are using isolated scope for you directive. So you canot access to the scope controller. You could comunicate with the controller using a service or using events.
If you want to be able to access to the controller scope directly from your directive, you have to use shared scope.
I hope that it helps. You can find more information about scopes in directives Here.
-----------EDIT----------------
I am not sure what you want to do, but in the next code you can see as using share scope, you can acces the data from de parent and making the view refresh with the new data.
angular.module('app').directive('scrollStop', function() {
return {
restrict: 'A',
scope: false,
link: function (scope, element, attrs) {
var $page = angular.element(window)
var $el = element[0]
var last_top = $el.getBoundingClientRect().top - 60;
$page.bind('scroll', function () {
var current_top = $el.getBoundingClientRect().top - 60;
if (last_top >= 0 && current_top < 0) {
// HERE!
scope.$parent.header=scope[attrs.scrollStop].header;
scope.$parent.$apply();
}
last_top = current_top;
})
}
}
});

Related

Instantiate an object so it can be shared with multiple directives

What I am doing: I am creating two attribute directives: One moves an element to the left, and another centers an element on the page. See code below. Notice that I'm manipulating ng-style to set those css properties (moving the element to the left, and centering on the page).
The Problem:
When I use both directives on an element, the second directive's scope.style={} obviously overwrites the first one.
This means that I'm not able to apply both ng-style/attributes at the same time.
My question:
What is an easy way to instantiate the scope.style object so that I can use it in both directives without recreating the object?
I'm trying to find a simple solution that can be scaled easily for multiple element directives without writing tons of messy code. (I don't want to create a special controller inside every directive to accommodate sharing the scope.style object).
Please advise. Thanks a lot!
Here is the html:
The data is from a json file, but that's not important here.
<!-- The Element: -->
<started-box center-page keepleft screen="screen[3]"></started-box>
<!-- The Template: -->
<div id="{{screen.id}}" ng-style="$parent.style">
Some content goes here.
</div>
Here are the AngularJS code snippets:
// Two Attribute directives:
app.directive("centerPage", function () {
return function (scope, element, attrs) {
var winW = window.innerWidth;
var winH = window.innerHeight;
var boxW = 370;
var boxH = 385;
scope.style = {};
scope.style.left = (winW / 2) - (boxW / 2);
scope.style.top = (winH / 2) - (boxH / 2);
}
});
app.directive("keepleft", function () {
return function (scope, element, attrs) {
scope.style = {};
scope.style.cursor = 'default';
scope.style.transform = 'translateX(-60%)';
}
});
// Directive for the template:
app.directive("startedBox", [function () {
return {
restrict: "E",
replace: true,
scope: {
screen: '='
},
templateUrl: dir + 'templates/directives/started-box.html'
};
}]);
Create a style service and inject it into both directives. Services are excellent for sharing states between directives/controllers.

Mixing Angular with MVC partial views

What I need
A chain of screens that each open the next screen on a button click. Each previous screen must be collapsed and the new screen must be added by loading a partial view from the MVC backend.
What I have
An AngularJS controller with the following function:
self.AddChild = function (uri, targetContainerId, collapseTitle, breadCrumbContainerId) {
var target = $("#" + targetContainerId);
if (target != 'undefined' && target != undefined && target.length > 0) {
apiService.Get(uri).then(function (viewData) {
self.CollapsePreviousChild(self.ChildCount);
// Increase childcount by 1
self.ChildCount += 1;
// Set HTML data
var html = '<div id="collapsibleScreen-"' + self.ChildCount + ' class="open">' + viewData + '</div>';
target.html(html);
// Update screens collapse status
self.UpdateScreenBreadCrumb(collapseTitle, breadCrumbContainerId);
});
};
}
The UpdateScreenBreadCrumb function works and is otherwise unrelated.
It is called (for instance) like this:
self.AddChild("/Partials/View1", "targetContainer", "View", "breadCrumbContainer");
What it does
The content of the View, a form, is loaded, the breadcrumb is updated correctly.
What I need fixed
On the partial view that was loaded, there is a button defined like this:
<button class="btn btn-primary" ng-click="AddPartialView()">Add partial view</button>
Clicking that button has no effect whatsoever. If I add a console.log('Code was here.') to the
AddPartialView(), it is not logged. Setting the ng-click value directly to alert('hello') has no effect either.
There are no errors of any kind visible.
Any suggestions on how to make this button work?
In regards to your question, you are adding HTML that isn't compiled by Angular. You need to use $compile on your newly added HTML element and then bind it to a scope. The $compile() function returns a link() function which you use to bind a scope to. Example:
$compile(new-element)(scope-to-bind-to)
NOTE: You should not be manipulating the DOM via a controller. This is considered bad practice. You should be using a custom directive or some combo of Angular directives (ngIf, ngSwitch, ngInclude). I recommend watching AngularJS best practices.
I've looked into $compile, as suggested by Itamar L. and got it to work. The samples I found were using Directives as well, so I implemented them anyway:
angular.module('directives.api').directive("PartialViewLoader", [
'$compile',
'chainedScreensService',
function (
$compile,
chainedScreensService) {
return {
restrict: 'A',
scope: {
view: '=',
parent: '='
},
controller: function() {
},
link: function (scope, element, attrs) {
chainedScreensService.GetPartialView(attrs.view).then(function (viewData) {
var linkFunc = $compile(viewData);
var content = linkFunc(scope);
element.append(content);
if (attrs.parent != 'undefined' && attrs.parent != undefined && attrs.parent.length > 0) {
chainedScreensService.CollapsePartialByIdentifier(attrs.parent);
}
});
}
}
}
]);
I use it like this:
<div ng-controller="collapseController">
<div id="breadCrumbContainer" style="display: inline"></div>
<div id="mainContainer">
<div id="personContainer" partial-view-loader view="persoon" parent="" class="open"></div>
</div>
</div>
That in itself displays the first page, which has a button to the next, as mentioned. The associated function, found in the collapseController, is this:
self.AddNextScreen = function (parentViewIdentifier, targetContainerId, breadCrumbContainerId) {
self.AddChildByDirective("NextScreen", parentViewIdentifier, targetContainerId, breadCrumbContainerId);
}
The code for AddChildByDirective:
self.AddChildByDirective = function (viewIdentifier, parentViewIdentifier, targetContainerId, breadCrumbContainerId) {
var html = '<div id="' + viewIdentifier + 'Container" fvl-partial-view-loader view="' + viewIdentifier + '" parent="' + parentViewIdentifier + '" class="open"></div>';
var target = $('#' + targetContainerId);
var linkFunc = $compile(html);
var content = linkFunc($scope);
target.append(content);
self.UpdateScreenBreadCrumb(viewIdentifier, breadCrumbContainerId);
}
At this point I still need to test actual chaining, but this works to load a new screen and collapse the previous.

AngularJS - communication between directives not working properly

I have the following html:
<div ng-controller="collapseController">
<div><breadcrumb-visualiser breadcrumbs="breadcrumbs" /></div>
<div id="partialViewContainer">
<div id="personContainer" partial-view-loader view="person" parent="" breadcrumbs="breadcrumbs"></div>
</div>
</div>
Template breadcrumb-visualiser
<div style="width: 100%; background-color: #eee">
<div ng-repeat="breadcrumb in breadcrumbs">
<span class="customBreadcrumb"><a ng-href="" ng-click="">{{breadcrumb}}</a></span>
</div>
</div>
partial-view-loader
Loads an MVC partial view into the containing div. The loading partial view will be able to add yet another (new) view to the screen, while hiding the previous screen.
As you may see by the above html, this directive shares the breadcrumbs binding, provided by the collapseController.
The directive adds the latest breadcrumb (associated to the just loaded partial view) to the existing breadcrumb list like so:
$scope.AddBreadCrumb = function (breadcrumb) {
$scope.breadcrumbs.push(breadcrumb);
}
That's a function in the directive controller.
The issue
The collapseController initialises breadcrumbs with this value ['A', 'B'].
So breadcrumbs A and B are displayed right away.
The first load of partial-view-loader will add breadcrumb C, resulting in:
['A', 'B', 'C'].
As I click the button that causes a new view to be added, I will once again trigger partial-view-loader, but now for breadcrumb D.
The problem is that it does not seem to update the breadcrumb. There's no visual change. Internally though, changes have been done but incorrectly.
If I add logging to AddBreadCrumb like so:
$scope.AddBreadCrumb = function (breadcrumb) {
console.log($scope.breadcrumbs);
$scope.breadcrumbs.push(breadcrumb);
console.log($scope.breadcrumbs);
}
I get the following output:
before: ['A', 'B']
- push -
after: ['A', 'B', 'D']
Question
How come the added breadcrumb 'C' isn't preserved, and why is the new breadcrumb list not displayed (even if it's incorrect)?
In response to Divya:
self.AddChildByDirective = function (viewIdentifier, parentViewIdentifier) {
var html = '<div id="' + viewIdentifier + 'Container" fvl-partial-view-loader view="' + viewIdentifier + '" parent="' + parentViewIdentifier + '" breadcrumbs="breadcrumbs" /></div>';
var target = $('#partialViewContainer');
var linkFunc = $compile(html);
var content = linkFunc($scope);
target.append(content);
chainedScreensService.CollapsePartialByIdentifier(parentViewIdentifier);
}
That builds, compiles and appends the directive code for the new screen (view and parent are different).
Extra info:
I've changed the scope of both directives to scope: false. The idea is to make sure I'm using the variables declared by the controller, not something in an isolated scope. Zero difference.
This is the current code of the directives:
breadcrumbVisualiser
angular.module('directives.api').directive("breadcrumbVisualiser", [
function () {
return {
restrict: 'E',
scope: false,
templateUrl: 'Templates/Directives/BreadcrumbVisualiser.html',
controller: function () {
},
link: function (scope, element, attrs) {
}
}
}
]);
partialViewLoader
angular.module('directives.api').directive("partialViewLoader", [
'$compile',
'chainedScreensService',
function (
$compile,
chainedScreensService) {
return {
restrict: 'A',
scope: false,
controller: ['$scope', function ($scope) {
}],
link: function (scope, element, attrs) {
chainedScreensService.GetPartialView(scope.activeView).then(function (viewData) {
$.post(viewData.Url, function(view) {
var linkFunc = $compile(view);
var content = linkFunc(scope);
element.append(content);
scope.AddBreadCrumb(viewData.Subject);
});
});
}
}
}
]);
scope.AddBreadCrumb(viewData.Subject) is a function defined in the controller.

Nested directives/controllers in angular

Just getting my head around Angular - failing to understand a few concepts as I come from the Backbone school of thought.
I've picked a random project to get started: a card game.
Let's say that I wanted to define a hand controller and a card controller. For simplicity, I want to have them as directives.
Here is the card directive:
app.directive('card', function(){
return {
restrict:'E',
templateUrl:'card.html',
controller:function($scope){
this.suit = 'clubs';
this.rank = 'a';
this.suitClass = function(){
return this.suit + '-' + this.rank;
}
},
controllerAs:'card'
};
});
And here is the hand directive:
app.directive('hand', function(){
return {
restrict:'E',
template:'hand.html',
controller:function($scope){
this.cards = [
{suit:'clubs', rank:'a'},
{suit:'spades', rank:'10'},
{suit:'hearts', rank:'2'},
{suit:'diamonds', rank:'k'}
];
},
controllerAs:'hand'
}
});
With the following plunker, I was expecting to be able to simply drop in the <hand></hand> element and have angular do all the work for me. In my minds eye there should be cards representing different suits nested within the <hand> directive. What am I missing? Currently, as you can tell in the plunker, the nested controller/directive does not instantiate the view properly.
Am I thinking in too much of an MVC way? Is OOP haunting me? Or is angular just badly designed?
I am not 100% sure that I understand your question but I think that this is a better way to write it:
var app = angular.module('app', []);
app.directive('card', function(){
return {
restrict:'E',
templateUrl:'card.html',
replace: true,
link: function ($scope, element, attrs){
$scope.suit = 'clubs';
$scope.rank = 'a';
$scope.suitClass = function(){
return this.suit + '-' + this.rank;
}
}
};
});
app.directive('hand', function($compile){
return {
restrict:'E',
templateUrl:'hand.html',
link:function($scope, element, attrs){
$scope.cards = [
{suit:'clubs', rank:'a'},
{suit:'spades', rank:'10'},
{suit:'hearts', rank:'2'},
{suit:'diamonds', rank:'k'}
];
}
}
});
And the html can be something like these:
(hand directive template)
<div>
<card ng-repeat="card in cards"></card>
</div>
And (card directive template)
<div ng-class="card.suitClass()">
{{ suit }}
</div>
I will explain the problem by going top down through the order of elements/objects that will be called:
hand directive:
The directive is ok so far. But the $compile parameter and the $scope parameter are not used an should be removed. To be more clear I applied this to a variable hand, but it does not change the behaviour of the application.
app.directive('hand', function(){
return {
restrict:'E',
templateUrl:'hand.html',
controller:function() {
var hand = this;
hand.cards = [
{suit:'clubs', rank:'a'},
{suit:'spades', rank:'10'},
{suit:'hearts', rank:'2'},
{suit:'diamonds', rank:'k'}
];
},
controllerAs:'hand'
}
});
hand.html:
You never passed the current card of the ng-repeat to the card directive.
That way you only produce the card templates times the number of card but never using the actual values.
I removed the obsolete div tag and enhanced the hand.html to this:
<card ng-repeat="card in hand.cards" card-model="card"></card>
This way I get every card from the hand view in the card directive.
card directive:
First I remove the $scope variable because it is never used and won't be used here.
This function is rather incomplete. At least it is missing the card values you want to use. But a major problem in here is that the context of this is bound to the caller. To be more precise, you are using this inside of the suitClass function, but you want to use the suit and rank values of the controller. this does not point to the controller function but to the newly created suitClass function which doesn't know any of these values. For that problem you should introduce a variable that holds the context and access the values that way. And I add the scope variable cardModel that is bound to the element attribute to get the desired values. And I add the bindToController: true to access the passed in model as card.cardModel instead of the pure cardModel:
app.directive('card', function(){
return {
restrict:'E',
scope: {
cardModel: '='
},
templateUrl:'card.html',
controller:function(){
var card = this;
console.log(card.cardModel)
card.suitClass = function(){
return card.cardModel.suit + '-' + card.cardModel.rank;
}
},
controllerAs:'card',
bindToController: true
};
});
card.html:
This view is okay. I only applied my changes:
<div ng-class="card.suitClass()">{{ card.cardModel.rank }}</div>
I hope it is still useful for anybody.

karma testing for $scope item not working / item not fired by $compile

I created a directive for my AngularJS app which checks the $scope to see if a profile image is present. If it is the directive shows the image by appending a new DIV, if not it shows a default via a css class. The directive looks like so... and it works in the app.
.directive('profileImage',function() {
return {
restrict: 'A',
scope: {
profileImage: '='
},
link: function(scope, element) {
if(!scope.profileImage) {
element.addClass('icon-default-profile-image defaultProfileImage');
} else {
element.append('<img ng-src="profileImage" class="profileImage">');
}
}
};
})
;
now I wish to write a unit test for this (I know I should have done that first but I am just learning about tests). I have written the following:
it('should display a specific image', inject(function($rootScope, $compile) {
//$rootScope.profileImage = "srcOfImage";
$scope = $rootScope;
$scope.profileImage = "srcOfImage";
var element = angular.element('<div profile-image="profileImage"></div>')
element = $compile(element)($scope)
console.log(element);
var imgElem = element.find('img');
expect(imgElem.length).toBe(1);
expect(imgElem.eq(0).attr('src')).toBe('srcOfImage');
}));
Now the test fails in this scenario (other tests where no $scope.profileImage is available has passed). When I out put the element I have created I get the following:
LOG: {0: <div profile-image="profileImage" class="ng-scope ng-isolate-scope"><img ng-src="profileImage" class="profileImage"></div>, length: 1}
So everything works but the profile-image="profileImage" in the var element = angular.element('<div profile-image="profileImage"></div>') is being taken as a literal string. It is not showing the value 'srcOfImage'. What am I doing wrong?
The error looks like so:
PhantomJS 1.9.2 (Mac OS X) Profile Image Directive should display a specific image FAILED
-src="prExpected undefined to be 'srcOfImage'.>, length: 1}
Big thanks for any advice / help / explanations
This is expected behavior because:
element.append('<img ng-src="profileImage" class="profileImage">');
appends a normal DOM image without compilation, so the properties are added as they are.
Try:
element.append('<img ng-src="profileImage" class="profileImage">');
$compile(element.contents())(scope); //adding this line to compile the image.
Remember to declare the $compile service in your directive:
.directive('profileImage',function($compile) {
One more problem is you have to use ng-src with {{}} like this:
ng-src="{{profileImage}}"
Your final directive looks like this:
.directive('profileImage',function($compile) { //declare $compile service.
return {
restrict: 'A',
scope: {
profileImage: '='
},
link: function(scope, element) {
if(!scope.profileImage) {
element.addClass('icon-default-profile-image defaultProfileImage');
} else {
element.append('<img ng-src="{{profileImage}}" class="profileImage">');
$compile(element.contents())(scope); //adding this line to compile the image.
}
}
};
})
;

Categories

Resources