I've built a directive that basically creates a markdown-friendly text editor. It will be used at various places throughout my site, anywhere an end user wants to use markdown to do some basic content styling (product descriptions, that sort of thing). The challenge is that as this directive will be deployed in multiple places, it won't be editing the same property on every model. For example, in one spot it may edit the LongDescription of a product, whereas in another spot it may edit the ShortDescription of an ad campaign, or the Bio of a user.
I need to be able to pass in the property that I want to edit to the directive using the scope '=' method that permits two-way data binding, so the property is changed both in the directive and on the original controller, allowing the user to save those changes. The problem that I'm having is that if I pass the property itself to the directive:
<markdown-editor model="product.Description"></markdown-editor>
two-way data binding doesn't work, since this passes the value of the Description property. I know that for the '=' method to two-way bind in a directive, I have to pass an object as the attribute value from my HTML. I can easily pass the entire object:
<markdown-editor model="product"></markdown-editor>
and then access the Description property within the directive:
<textarea ng-model="model.Description"></textarea>
but this hardcodes Description into the directive, and I may not always want that property.
So my question is, how can I two-way bind to a single property of my object, without the directive knowing ahead of time what that property is? I've come up with a workaround but it's pretty ugly:
HTML:
<markdown-editor model="contest" property="Description"></markdown-editor>
Directive JS:
angular.module('admin.directives').directive('markdownEditor', [
'admin.constants.templateConstants',
'$sce',
function (Templates, $sce) {
var directive = {
restrict: 'E',
replace: true,
templateUrl: Templates.Directives.MarkdownEditor,
scope: {
model: '=',
property: '#'
},
controllerAs: 'markdownEditor',
controller: markdownEditorController
}
function markdownEditorController($scope) {
var vm = this;
vm.display = { markdown: true };
vm.content = { markdown: '', html: '' };
console.log($scope.model);
vm.setDisplay = function (type) {
vm.display = {};
vm.display[type] = true;
}
$scope.$watch('model', function (newModel, oldModel, $scope) {
vm.content.markdown = $scope.model[$scope.property];
});
$scope.$watch('markdownEditor.content.markdown', function (newDescription, oldDescription, $scope) {
$scope.model[$scope.property] = newDescription;
if (newDescription !== "" && newDescription !== null && newDescription !== undefined) {
vm.content.html = $sce.trustAsHtml(marked(newDescription));
}
});
}
return directive;
}
]);
Relevant part of the directive template:
<textarea class="ad-basic-input" ng-model="markdownEditor.content.markdown" ng-if="markdownEditor.display.markdown"></textarea>
Notice that the directive uses a watch to look for changes on the content.markdown field, then pushes those back into model[property] manually (the second $watch near the bottom). It also has to $watch for changes to the model being passed in from the controller above because that's being loaded asynchronously, and needs to be assigned to the content.markdown field initially.
This code works, but having these two watches, especially the one that looks for changes on the model, seems like a big code smell to me. Surely there must be a better way to pass in, edit, and two-way bind a single property of an object on the controller, when that property is unknown?
Thanks!
Related
I am relatively new to AngularJS. While venturing into directive creation, I can across this problem: How to dynamically add / remove attributes on the children of the directive's element when these children are dynamically added with 'ng-repeat'?
First, I thought of this solution:
template
...
a.list-group-item(ng-repeat='playlist in playlists', ng-click='addToPlaylist(playlist, track)', ng-href='playlist/{{ playlist._id }})
...
*directive
link: function(scope, elm, attrs) {
var listItems = angular.element(element[0].getElementsByClassName('list-group-item')
angular.forEach(listItems, function(item, index) {
'add' in attrs ? item.removeAttr('href') : item.removeAttr('ng-click');
listItems[index] = item;
}
...
Result
It turns out, my code never enters this angular.forEach loop because listItems is empty. I suppose it's because the ng-repeat is waiting for the scope.playlists to populate with the data from a async call to a server via $resource.
temporary fix
in the directive definition, I added a boolean variable that checks for the presence of 'add' in the element's attributes: var adding = 'add' in attrs ? true : false;
And then in the template,
a.list-group-item(ng-if='adding', ng-repeat='playlist in playlists', ng-click='addToPlaylist(playlist, track)')
a.list-group-item(ng-if='!adding', ng-repeat='playlist in playlists', ng-href='playlist/{{playlist._id }}')
While it works fine, it is obviously not DRY at all. HELP!
Instead of removing attributes, change your click handler.
Add $event to the list of arguments and conditionally use preventDefault().
<a ng-click='addToPlaylist($event,playlist)' ng-href='playlist'>CLICK ME</a>
In your controller:
$scope.addToPlaylist = function(event,playlist) {
if (!$scope.adding) return;
//otherwise
event.preventDefault();
//do add operation
};
When not adding, the function returns and the href is fetched. Otherwise the default is prevented and the click handler does the add operation.
From the Docs:
$event
Directives like ngClick and ngFocus expose a $event object within the scope of that expression. The object is an instance of a jQuery Event Object when jQuery is present or a similar jqLite object.
-- AngularJS Developer Guide -- $event
The way that you are trying to do things may not be the most Angularish (Angularist? Angularyist?) way. When using angular.element() to select child elements as you are trying to do here, you can make sure the child elements are ready as follows:
link: function(scope, elm, attrs) {
elm.ready(function() {
var listItems = angular.element(element[0].getElementsByClassName('list-group-item')
angular.forEach(listItems, function(item, index) {
'add' in attrs ? item.removeAttr('href') : item.removeAttr('ng-click');
listItems[index] = item;
}
});
}
However, this is unlikely to work in your situation, as #charlietfl points out below. If you want to avoid the solution you already have (which I think is better than your first attempt), you will have to reimplement your code altogether.
I would suggest defining an additional directive that communicates with its parent directive using the require property of the directive definition object. The new directive would have access to an add property of the parent (this.add in the parent directive's controller) and could be programmed to behave accordingly. The implementation of that solution is beyond the scope of this answer.
Update:
I decided to give the implementation something of a shot. The example is highly simplified, but it does what you are trying to do: alter the template of a directive based on the attributed passed to it. See the example here.
The example uses a new feature in Angular 1: components. You can read more about injectable templates and components here. Essentially, components allow you to define templates using a function with access to your element and its attributes, like so:
app.component('playlistComponent', {
// We can define out template as a function that returns a string:
template: function($element, $attrs) {
var action = 'add' in $attrs
? 'ng-click="$ctrl.addToPlaylist(playlist, track)"'
: 'ng-href="playlist/{{playlist._id}}"';
return '<a class="list-group-item" ng-repeat="playlist in playlists" ' +
action + '></a>';
},
// Components always use controllers rather than scopes
controller: ['playlistService', function(playlists) {
this.playlists = playlists;
this.addToPlaylist = function(playlist, track) {
// Some logic
};
}]
});
I need your help ! :)
I'm quite new to AngularJS (v1.2) development, and I'm playing with directives.
The problem
I pass a parent ctrl model attr to my directive, that has its inner model in its isolated scope. The 2-ways data binding works fine between the parent ctrl attr and the directive's scope attr.
My problem is then that I want to update the directive's model attr from an external callback function of my parent ctrl, WITHOUT knowing which is the parent ctrl attribute to update...
This is because an event from my directive (like add an element in a list) must perform an async call to a REST WS to save the new element in the back-end, and update the angularJS associated model with the 'fresh' data received in the response (for example my data with an ID after it has been saved in the database...)
Reproduce it
I don't know if this is clear, it should be hard to understand "as is", so I made a JSFiddle to demonstrate this with a very basic example : TEST IT HERE.
If you type something in the "INTERNAL Callback" input text, you will call a method in the directive's scope. The model received from the async call (for example "{id:100, name:'JSFiddle'}") will be set to the directive's inner model, and reflected to the parent's scope model as well. FINE !
BUT if you type something in the "EXTERNAL Callback" input text, you will call a method in the parent's controller scope, with a 'model' method attribute that is the directive's scope attribute. The model received from the async call (for example "{id:100, name:'JSFiddle'}") will be set to the callback method model, and NOT reflected to the parent's scope model NOR the directive's model...
The only way I've found is to pass the directive's scope as the external callback attribute, and then update the desired attribute in this scope. That will work, but this is strange and awful in my opinion to pass the whole directive's scope to the callback method... You can see it in action HERE.
Of course, you can tell me that I just have to update my parent controller model in my external callback method, but as I can use MANY directives in the same view/controller, used in "ng-repeat" items, using the same callback method, I can't know which parent ctrl scope attribute I should update... See this case HERE.
Any ideas ?
The example code
HTML :
<div ng-controller="MyCtrl">
<h1>Parent Ctrl</h1>
<p>ctrlModel.id = {{ctrlModel.id}}</p>
<p>ctrlModel.name = {{ctrlModel.name}}</p>
<hr/>
<h1>Directive</h1>
<my-directive my-model="ctrlModel" on-change="ctrlOnChange(model)"></my-directive>
</div>
JAVASCRIPT:
var myApp = angular.module('myApp',[]);
function MyCtrl($scope, $log) {
$scope.ctrlModel = {id:0, name:'StackOverflow'};
$scope.ctrlOnChange = function(paramModel){
$log.info('MyCtrl.ctrlOnChange / paramModel BEFORE = ' + JSON.stringify(paramModel));
// Async call to save the model remotely and get it with an ID for example
/*
$http.post('/api/save-this-model-please', model)
.then(function(response){
model = response.data;
// Here my LOCAL METHOD model 'paramModel' will be updated with saved data, including an ID !== 0
// 'ctrlModel' in PARENT scope of 'MyCtrl' AND DIRECTIVE model 'scope.directiveModel' are not synced with these "fresh" data...
});
*/
paramModel = {id:100, name:'JSFiddle'};
$log.info('MyCtrl.ctrlOnChange / paramModel AFTER = ' + JSON.stringify(paramModel));
};
}
myApp.directive('myDirective', function ($log) {
return {
restrict: 'E',
template: '<div><p>directiveModel.id = {{directiveModel.id}}</p><p>directiveModel.name = {{directiveModel.name}}</p><p>INTERNAL Callback : <input ng-model="directiveModel.name" ng-change="onChangeInternalCallback()"></p><p>EXTERNAL Callback : <input ng-model="directiveModel.name" ng-change="onChangeExternalCallback({model:directiveModel})"></p></div>',
replace: true,
transclude: false,
scope: {
directiveModel: '=myModel',
onChangeExternalCallback: '&onChange'
},
link: function (scope) { // , element, attrs
scope.onChangeInternalCallback = function(){
$log.info('myDirective.onChangeInternalCallback / directiveModel BEFORE = ' + JSON.stringify(scope.directiveModel));
// Async call to save the model remotely and get it with an ID for example
/*
$http.post('/api/save-this-model-please', scope.directiveModel)
.then(function(response){
scope.directiveModel = response.data;
// Here my DIRECTIVE model 'scope.directiveModel' will be updated with saved data, including an ID !== 0
// With 2-ways data binding, 'ctrlModel' in PARENT scope of 'MyCtrl' is ALSO synced with these "fresh" data...
});
*/
scope.directiveModel = {id:100, name:'JSFiddle'};
$log.info('myDirective.onChangeInternalCallback / directiveModel AFTER = ' + JSON.stringify(scope.directiveModel));
};
}
};
});
I'm tring to write a directive that builds an object from its child directive's input and pushes it
to an array provided as a parameter. Something like:
<aggregate-input on="SiteContent.data.objList">
<p aggregate-question>Question text</p>
<input aggregate-answer ng-model="answer" type="text" />
</aggregate-input>
<aggregate-input on="SiteContent.data.objList">
<p aggregate-question>Question text 2</p>
<input aggregate-answer ng-model="answer" type="text" />
</aggregate-input>
I'm looking to collect the data like:
SiteContent.data.objList === [
{question: 'Quesion text', answer: 'user input'},
{question: 'Quesion text 2', answer: 'user input 2'},
];
Here's the plunker with the code.
update 1: #jrsala included scope and bindToController syntax changes
I'm having trouble figuring out the way these directives should communicate. I'm expecting the input
object defined in the link will be isolated in the scope of each directive and pushed to the on object
provided. The result is that the input object is shared among all instances, and only one object gets
ever pushed to the array.
I'm guessing the transcluded scope rules are confusing me, but I really don't see where. Any ideas? Thanks!
First issue: your aggregate-input directive specifies an isolate scope with no bound properties but you still use an on attribute on the element with the directive on it:
<aggregate-input on="SiteContent.data.objList">
but in your JS,
{
restrict: 'E',
scope: {},
controller: aggregateInputController,
controllerAs: 'Aggregate',
bindToController: { on: '=' },
/* */
}
whereas what you need is
{
restrict: 'E',
scope: { on: '=' },
controller: aggregateInputController,
controllerAs: 'Aggregate',
bindToController: true // BOOLEAN REQUIRED HERE, NO OBJECT
}
As per the spec's paragraph on bindToController,
When an isolate scope is used for a component (see above), and controllerAs is used, bindToController: true will allow a component to have its properties bound to the controller, rather than to scope. When the controller is instantiated, the initial values of the isolate scope bindings are already available.
Then you do not need to assign the on property to your controller, it's done for you by Angular (also I did not understand why you did this.on = this.on || [], the this.on || part looks unnecessary to me).
I suppose you can apply that to the rest of the code and that should be a start. I'm going to look for more issues.
edit: Several more issues that I found:
If the scope of siteContent is isolated then the SiteContent controller is not accessible when the directive is compiled and Angular silently fails (like always...) when evaluating SiteContent.data.objList to pass it to the child directives. I fixed that by removing scope: {} from its definition.
It was necessary to move the functionality of aggregateInputLink over to aggregateInputController because, as usual, the child controllers execute before the link function, and since the aggregateQuestion directive makes the call InputCtrl.changeValue('question', elem.text()); in its controller, the scope.input assigned to in the parent directive post-link function did not exist yet.
function aggregateInputController($scope) {
$scope.input = {};
this.on.push($scope.input);
this.changeValue = function changeValue(field, value) {
$scope.input[field] = value;
};
}
As a reminder: controllers are executed in a pre-order fashion and link functions in a post-order fashion during the traversal of the directive tree.
Finally, after that, the SiteContent controller's data did not get rendered property since the collection used to iterate over in ng-repeat was erroneously SiteContent.objList instead of SiteContent.data.objList.
Link to final plunker
I have a directive where a list(array) of items is passed in through the scope of the controller into the scope of the directive whereby the template of the directive then has access to the items.
I would like to have it so that the list of items is passed to the directive (where it is then used within the link function) and then not directly accessible through the directive's template.
i.e. if we had the following directive:
directive('itemList', function(){
return {
scope: {
items: '='
}
link: function(scope, elem, attrs){
var item-list = scope.items;
// ... want to manipulate first ...
}
}
})
the variable scope.items is now available to any template that the directive uses. Whereas I don't want that to be the case and would like to pass in something to the directive without making it known to the scope. Is this possible?
Since the directive scope pretty much by definition is the scope used by the directive's template, I don't see any obvious way of doing this in a strict information hiding way. But, why not just use a different name for the passed scope variable and what the template binds to? For example, if you said scope: { myPrivatePassedItems: '=items' }, you can now work with scope.myPrivatePassedItems as much as needed before setting it as scope.items. With this approach, the HTML in both the usage of the directive and the directive's template just sees "items", but internally your directive has its own "private" variable.
I should add that the above is the simple change needed for the one-way data flow from the consumer to the directive template. If you also need to update the original items array, you will also want to add a scope.$watch on the scope.items array after you have done your initial setup. You would then need to carry those changes (possibly with modifications) back to scope.myPrivatePassedItems in order to update the consumer's array.
You can use the $parse service to retrieve the value without using scope: {}, but you will lose the 2 way data binding that you inherently get from using scope: { items: '=' }. As mentioned by Dana Cartwright, if you need 2 way binding, you have to set this up manually with watches.
directive('itemList', function($parse){
return {
link: function(scope, elem, attrs){
var itemList = $parse(attrs['items'])(scope);
// do stuff with itemList
// ...
// then expose it
scope.itemList = itemList;
}
};
});
I have a directive that takes in a collection and builds out a dropdown.
.directive("lookupdropdown", function () {
return {
restrict: 'E',
scope: {
collectionset: '=',
collectionchoice: '='
},
replace: true,
template: '<select class="input-large" ui-select2 ng-model="collectionchoice" data-placeholder="">' +
' <option ng-repeat="collection in repeatedCollection" value="{{collection.id}}">{{collection.description}}</option>' +
'</select>',
controller: ["$scope", function ($scope) {
$scope.repeatedCollection = new Array(); //declare our ng-repeat for the template
$scope.$watch('collectionset', function () {
if ($scope.collectionset.length > 0) {
angular.forEach($scope.collectionset, function (value, key) { //need to 'copy' these objects to our repeated collection array so we can template it out
$scope.repeatedCollection.push({ id: value[Object.keys(value)[0]], description: value[Object.keys(value)[1]] });
});
}
});
$scope.$watch('collectionchoice', function (newValue, oldValue) {
debugger;
$scope.collectionchoice;
});
} ]
}
});
This works fine. It builds out the drop down no problem. When I change the dropdown value, the second watch function gets called and I can see that it sets the value of collection choice to what I want. However, the collectionchoice that I have put into the directive doesn't bind to the new choice.
<lookupDropdown collectionset="SecurityLevels" collectionchoice="AddedSecurityLevel"></lookupDropdown>
That is the HTML markup.
This is the javascript:
$scope.SecurityLevels = new Array();
$scope.GetSecurityLevelData = function () {
genericResource.setupResource('/SecurityLevel/:action/:id', { action: "#action", id: "#id" });
genericResource.getResourecsList({ action: "GetAllSecurityLevels" }).then(function (data) {
$scope.AddedSecurityLevel = data[0].SCRTY_LVL_CD;
$scope.SecurityLevels = data;
//have to get security levels first, then we can manipulate the rest of the page
genericResource.setupResource('/UserRole/:action/:id', { action: "#action", id: "#id" });
$scope.GetUserRoles(1, "");
});
}
$scope.GetSecurityLevelData();
Then when I go to post my new user role, I set the user role field like this:
NewUserRole.SCRTY_LVL_CD = $scope.AddedSecurityLevel;
but this remains to be the first item EVEN though I have updated the dropdown, which according the watch function, it has changed to the correct value. What am I missing here?
You faced this issue because of the prototypical nature inheritance in Javascript. Let me try and explain. Everything is an object in Javascript and once you create an object, it inherits all the Object.Prototype(s), which eventually leads to the ultimate object i.e. Object. That is why we are able to .toString() every object in javascript (even functions) because they are all inherited from Object.
This particular issue on directives arises due to the misunderstanding of the $scope in Angular JS. $scope is not the model but it is a container of the models. See below for the correct and incorrect way of defining models on the $scope:
...
$scope.Username = "khan#gmail.com"; //Incorrect approach
$scope.Password = "thisisapassword";//Incorrect approach
...
$scope.Credentials = {
Username: "khan#gmail.com", //Correct approach
Password: "thisisapassword" //Correct approach
}
...
The two declarations make a lot of difference. When your directive updated its scope (isolated scope of directive), it actually over-rid the reference completely with the new value rather then updating the actual reference to the parent scope hence it disconnected the scope of the directive and the controller.
Your approach is as follows:
<lookupDropdown collectionset="SecurityLevels" collectionchoice="$parent.AddedSecurityLevel"></lookupDropdown>
The problem with this approach is that although it works, but it not the recommended solution and here is why. What if your directive is placed inside another directive with another isolated scope between scope of your directive and the actual controller, in that case you would have to do $parent.$parent.AddedSecurityLevel and this could go on forever. Hence NOT a recommended solution.
Conclusion:
Always make sure there is a object which defines the model on the scope and whenever you make use of isolate scopes or use ng directives which make use of isolate scopes i.e. ng-model just see if there is a dot(.) somewhere, if it is missing, you are probably doing things wrong.
The issue here was that my directive was being transcluded into another directive. Making the scope im passing in a child of the directive it was in. So something like $parent -> $child -> $child. This of course was making changes to the third layer and second layer. But the first layer had no idea what was going on. This fixed it:
<lookupDropdown collectionset="SecurityLevels" collectionchoice="$parent.AddedSecurityLevel"></lookupDropdown>