AngularJS: Should I observe optional attributes? - angularjs

Assume I have a directive my-button with an optional attribute disabled. People could use this directive like:
<my-button>Button text</my-button>
or
<my-button disabled="variable">Button Text</my-button>
Should I be watching for a disabled attribute? Could these usages somehow transition from one to the other?
In response to JB Nizet's request for the code in question, here's a clean version of the directive function:
function ButtonDirective() {
var directive = {
link: link,
restrict: 'E',
scope: {
click: '&',
disabled: '=?',
},
template: '<a class="my-button" ' +
'data-ng-class="{\'my-button-disabled\': disabled}" ' +
'data-ng-click="disabled || click()" ng-transclude />',
transclude: true
};
function link(scope) {
if (typeof scope.disabled == 'undefined') scope.disabled = false;
}
return directive;
}
The directive creates an anchor tag styled as a button. It accepts two properties/parameters: click and disabled. The latter is optional. When disabled, the click event should fire when clicked, otherwise the the click event should fire when clicked.
To reiterate: Should I worry about someone somehow adding, removing, or modifying the disabled attribute after the fact? If so, how should I go about it?

After hashing things out with JB Nizet, he counseled me to not worry about the HTML attribute changing.

Related

ngClick and ngDisabled on templated anchor tag

I'm working on the following directive:
module.directive('myButton', function () {
var directive = {
compile: compile,
restrict: 'E',
scope: {
type: '#',
click: '&',
disabled: '#'
},
template: '<a class="my-button my-button-{{type}}" data-ng-click="disabled || click()"><ng-transclude /></a>',
transclude: true
};
function compile(element, attributes) {
if (typeof attributes.click == 'undefined') attributes.click = function () { };
if (typeof attributes.disabled== 'undefined') attributes.disabled = false;
}
return directive;
});
And I'm got some dummies on a page that look like this:
<my-button type="1a" click="alert('Button 1a works!')" disabled="false">Test A</my-button>
I can't seem to get the click and disabled functions of the button working (at least not at the same time). I've styled the anchor tag so it looks like a button. Anchor tags aren't affected by the disabled attribute, so the ngClick has to check if it should fire.
How can I get the click and disabled functionalities to work as expected?
You could achieve this by having && condition over it instead of ||. Basically if disabled flag is true then ng-click expression would not evaluate further. If its false then will call click method. Look at attached plunkr example.
data-ng-click="!disabled && click()"
Demo here
Change disabled: '#' to disabled: '='.
Read-only parameters ('#') are passed in as strings regardless of the type of the variable assigned to the parameter. You want to use a boolean; a two-way parameter ('=') persists the type.

Passing a model to a custom directive - clearing a text input

What I'm trying to achieve is relatively simple, but I've been going round in circles with this for too long, and now it's time to seek help.
Basically, I have created a directive that is comprised of a text input and a link to clear it.
I pass in the id via an attribute which works in fine, but I cannot seem to work out how to pass the model in to clear it when the reset link is clicked.
Here is what I have so far:
In my view:
<text-input-with-reset input-id="the-relevant-id" input-model="the.relevant.model"/>
My directive:
app.directive('textInputWithReset', function() {
return {
restrict: 'AE',
replace: 'true',
template: '<div class="text-input-with-reset">' +
'<input ng-model="inputModel" id="input-id" type="text" class="form-control">' +
'<a href class="btn-reset"><span aria-hidden="true">×</span></a>' +
'</div>',
link: function(scope, elem, attrs) {
// set ID of input for clickable labels (works)
elem.find('input').attr('id', attrs.inputId);
// Reset model and clear text field (not working)
elem.find('a').bind('click', function() {
scope[attrs.inputModel] = '';
});
}
};
});
I'm obviously missing something fundamental - any help would be greatly appreciated.
You should call scope.$apply() after resetting inputModel in your function where you reset the value.
elem.find('a').bind('click', function() {
scope.inputModel = '';
scope.$apply();
});
Please, read about scope in AngularJS here.
$apply() is used to execute an expression in angular from outside of the angular framework. (For example from browser DOM events, setTimeout, XHR or third party libraries). Because we are calling into the angular framework we need to perform proper scope life cycle of exception handling, executing watches.
I've also added declaring of your inputModel attribute in scope of your directive.
scope: {
inputModel: "="
}
See demo on plunker.
But if you can use ng-click in your template - use it, it's much better.
OK, I seem to have fixed it by making use of the directive scope and using ng-click in the template:
My view:
<text-input-with-reset input-id="the-relevant-id" input-model="the.relevant.model"/>
My directive:
app.directive('textInputWithReset', function() {
return {
restrict: 'AE',
replace: 'true',
scope: {
inputModel: '='
},
template: '<div class="text-input-with-reset">' +
'<input ng-model="inputModel" id="input-id" type="text" class="form-control">' +
'<a href ng-click="inputModel = \'\'" class="btn-reset"><span aria-hidden="true">×</span></a>' +
'</div>',
link: function(scope, elem, attrs) {
elem.find('input').attr('id', attrs.inputId);
};
});
It looks like you've already answered your question, but I'll leave my answer here for further explanations in case someone else lands on the same problem.
In its current state, there are two things wrong with your directive:
The click handler will trigger outside of Angular's digest cycle. Basically, even if you manage to clear the model's value, Angular won't know about it. You can wrap your logic in a scope.$apply() call to fix this, but it's not the correct solution in this case - keep reading.
Accessing the scope via scope[attrs.inputModel] would evaluate to something like scope['the.relevant.model']. Obviously, the name of your model is not literally the.relevant.model, as the dots typically imply nesting instead of being a literal part of the name. You need a different way of referencing the model.
You should use an isolate scope (see here and here) for a directive like this. Basically, you'd modify your directive to look like this:
app.directive('textInputWithReset', function() {
return {
restrict: 'AE',
replace: 'true',
template: [...],
// define an isolate scope for the directive, passing in these scope variables
scope: {
// scope.inputId = input-id attribute on directive
inputId: '=inputId',
// scope.inputModel = input-model attribute on directive
inputModel: '=inputModel'
},
link: function(scope, elem, attrs) {
// set ID of input for clickable labels (works)
elem.find('input').attr('id', scope.inputId);
// Reset model and clear text field (not working)
elem.find('a').bind('click', function() {
scope.inputModel = '';
});
}
};
});
Notice that when you define an isolate scope, the directive gets its own scope with the requested variables. This means that you can simply use scope.inputId and scope.inputModel within the directive, instead of trying to reference them in a roundabout way.
This is untested, but it should pretty much work (you'll need to use the scope.$apply() fix I mentioned before). You might want to test the inputId binding, as you might need to pass it a literal string now (e.g. put 'input-id' in the attribute to specify that it is a literal string, instead of input-id which would imply there is an input-id variable in the scope).
After you get your directive to work, let's try to make it work even more in "the Angular way." Now that you have an isolate scope in your directive, there is no need to implement custom logic in the link function. Whenever your link function has a .click() or a .attr(), there is probably a better way of writing it.
In this case, you can simplify your directive by using more built-in Angular logic instead of manually modifying the DOM in the link() function:
<div class="text-input-with-reset">
<input ng-model="inputModel" id="{{ inputId }}" type="text" class="form-control">
<span aria-hidden="true">×</span>
</div>
Now, all your link() function (or, better yet, your directive's controller) needs to do is define a reset() function on the scope. Everything else will automatically just work!

Parent $scope in two way binding directive not updating correctly

I was simply trying to add functionality to an existing directive so I can track any changes that occur. I have a toggle control which is just used to toggle a boolean value. Here is my directive
app.directive('skToggle', function ($timeout) {
return {
replace: true,
restrict: 'A',
require: 'ngModel',
scope: {
ngModel: '=',
skcallback: '&callback',
disabled: '=',
emit: '#',
positive: '#',
negative: '#',
skTouched:'=?' //THIS IS NEW
},
template: '<div class="toggle" ng-class="{ disabled: disabled }" ng-click="disabled || toggle($event)">\
<div class="off">\
<span>{{neg}}</span>\
</div>\
<div class="on" ng-class="{ active: ngModel }">\
<span>{{pos}}</span>\
<div class="control"></div>\
</div>\
</div>',
link: function (scope, elem, attrs, ctrl) {
var hasCallback = angular.isDefined(attrs.callback);
scope.pos = scope.positive || "YES"
scope.neg = scope.negative || "NO";
scope.toggle = function (e) {
if (hasCallback) {
scope.skcallback({ event: e });
} else {
ctrl.$setViewValue(!ctrl.$viewValue);
}
if (scope.emit === 'true') {
$timeout(function () {
scope.$emit('toggle');
});
}
}
// THIS IS ALSO NEW
scope.$watch('ngModel', function(newVal, oldVal){
if(scope.skTouched && oldVal !== undefined && newVal !== oldVal){
scope.skTouched.UI.$dirty = true;
}
});
}
}
});
I have commented on which parts of the directive are new.. all I did was add a two-way binding on my directive that takes an object and updates the UI.$dirty property on it. The problem is when I print out the object on the screen, the UI object never gets updated on the parent $scope. I don't know if I'm just spacing on something easy or if I am doing something wrong but, my directive (child) scope is not updating the parent scope like it should be.
<div sk-toggle ng-model="feature.enabled" sk-touched="feature"></div>
So I realized the problem was with using $dirty as my variable. Angular must reserve $dirty for only use with form controllers, which explains why it wasn't showing up when I printed the entire object out on the page. Simply changing the variable to dirty made it work as expected
ngModel is a core angular directive. It may be that there is a conflict with your using ngModel as an attribute to use two-way databinding on.
You may be able to get around this by setting the priority of your directive to be higher than that of ngModel. Add a priority of something > 1 in your directive definition. See the docs for more info on priority.

Angular-UI-Bootstrap custom tooltip/popover with 2-way data-binding

I am using angular-ui-bootstrap in my current project, and I have a requirement for a popover that will allow the user to take some action on a given element (rename/edit/delete/etc...). Since angular-ui's bootstrap popover doesn't allow for custom html or data-binding by default, I have copied their tooltip/popover .provider and .directive in an effort to customize it to my needs.
Main Problem: The ng-click bindings are being lost after the popup is closed and re-opened.
Secondary Problem: Can two-way data-binding be setup so that I don't have to manually set scope.$parent.$parent.item?
Plunker: http://plnkr.co/edit/HP7lZt?p=preview
To give glance of what changes were made to the original tooltip.js:
The popover .directive is what has been modified the most:
.directive('iantooltipPopup', function () {
return {
restrict: 'E',
replace: true,
scope: { mediaid: '#', title: '=', content: '#', placement: '#', animation: '&', isOpen: '&' },
templateUrl: 'popover.html',
link: function (scope, element, attrs) {
scope.showForm = false;
scope.onRenameClick = function () {
console.log('onRenameClick()');
scope.showForm = true;
};
scope.onDoneClick = function () {
console.log('Title was changed to: ' + scope.title);
scope.showForm = false;
scope.$parent.$parent.item.title = scope.title;
scope.$parent.hide();
};
}
};
})
The tooltip .provider was only changed here, in an effort to get two-way binding to work on the title field :
var template =
'<'+ directiveName +'-popup '+
// removed
// 'title="'+startSym+'tt_title'+endSym+'" '+
'title="tt_title" ' +
'content="'+startSym+'tt_content'+endSym+'" '+
'placement="'+startSym+'tt_placement'+endSym+'" '+
'animation="tt_animation()" '+
'is-open="tt_isOpen"'+
'>'+
'</'+ directiveName +'-popup>';
I appreciate any help, I feel the compiled directives and providers seem to be large mental hurdles when getting started with Angular. I've been trying figure out and manipulate this directive so I can learn from it, just as much as actually needing the component itself.
Here is the working plunker
The problem is from the original tooltip. It removes the tooltip after you close but next time when you open it, it doesn't compile the tooltip again. (link function for the tooltip trigger only run in the first time.)
My approach is don't remove the tooltip, just control it by display attribute from CSS.
I also make a pull request to discuss this issue.
I just update the plunker.
The 2nd one is actually just make it link with the parent scope. However, it will create a child scope with my approach. I think you can use watch to do it as well.

How to overwrite ng-click functionality

I'm trying to take a form button that says Save to change to Saving... when it's busy. It'd be awesome if this could detect that there's an ng-click directive here and only trigger that directive if busy is false. Would I need to create a new directive for this, or is there a way to just tap into ng-click's functionality?
HTML:
<button-large color="green" ng-click="createWorkstation()" busy="disableSave()" busyLabel="Saving...">Save</button-large>
JS:
directive('buttonLarge', function () {
return {
scope: {
busy: '&'
},
replace: true,
restrict: 'E',
transclude: true,
template: '<button type="checkbox" class="buttonL" ng-transclude/>',
link: function (scope, element, attrs) {
var config = {
color: "Default"
};
angular.extend(config, attrs);
element.addClass("b"+capitalize(config.color));
//when the button is busy, disable the button
scope.$watch(attrs.busy, function () {
console.log('changed', scope.busy);
});
//capitalize first letter of string
function capitalize(s) {
return s[0].toUpperCase() + s.slice(1);
}
}
}
})
I'd just do it like this:
<button ng-click="createWorkstation()">{{isBusy && "Saving" || "Save"}} </button>
Where isBusy can just be a boolean that you are changing in your scope (or a function I guess) while you are processing/etc. This doesn't require a directive and keeps the wording in the markup. You could probably extend this to having a service or constant for the strings/etc but that just depends on how far you want to take it.
** update **
If you want to bind html per one of the comments you'd use the ng-bind-html-unsafe directive:
<button ng-click="go()" ng-bind-html-unsafe="isBusy && '<span class=icol-refresh></span><span>Saving...</span>' || 'Save'">Process</button>

Resources