dynamic grid as custom directive - javascript

I am relatively new to Angular and got stuck on a custom directive.
I am trying to create a dynamic grid as a custom directive.
I already got that part working as in this example:
working grid as custom directive
There are certain scenarios where I need to set attributes on some of the elements of the grid.
This part has got me stumped.
I am planning on including the attributes as an array inside the object and then just putting it in the html tag of the associated entry.
This part is demonstrated here:
broken grid as custom directive with dynamic attributes
If you look at the "entries" array in the controller, I have now changed it to include an "attributes" array which will contain objects specifying the attribute name and property. These attributes should then be applied to the associated column.
e.g.
(First entry of the array)
col1: {
text: 'Obj1.col1',
attributes: [{
attr: 'ng-class',
attrVal: 'propVal == "" ? "someClass" : "someOtherClass"'
}, {
attr: 'id',
attrVal: '{{propName}}{{$index}}'
}]
},
...Truncated for brevity
This array entry should then be translated to:
<td ng-class="propVal == '' ? 'someClass' : 'someOtherClass'" id="col11">Obj1.col1</td>
I have read a couple of articles about the execution order of compile, controller, pre-link and post-link functions and have played around with different orders and trying to invoke compiling myself, but it all has failed.
Probably because I lack a deeper understanding of how it all ties together.
If someone can help me out or point me in the right direction if I'm heading down the wrong path, I would greatly appreciate that.

Okay, I finally figured out how to generate the grid dynamically using embedded custom directives inside a parent custom directive.
Here is a plunker showing how I did it:
Plunker with working dynamic grid
I have the Html templates defined as:
<div ng-grid ng-collection="entries" ng-collection-headings="headings" ng-button-click="theAction(inp)">
<div ng-checkbox-column></div>
</div>
and then the ng-grid directive as:
.directive("ngGrid", function () {
return {
restrict: "A",
scope: {
ngCollectionHeadings: "=",
ngCollection: "=",
ngButtonClick: "&"
},
template: function (element, attrs) {
var children = element.html();
children = children.trim().replace(/div/g, "td");
var htmlText = "<input type='button' ng-click='buttonClicked()' value='From the grid directive' /><table class='table table-bordered'><thead><tr><th ng-repeat='heading in ngCollectionHeadings'>{{heading}}</th></tr></thead><tbody><tr id='item{{$index}}' ng-repeat='item in ngCollection'>" + children + "</tr></tbody></table>";
return htmlText;
},
controller: function ($scope, $element) {
$scope.buttonClicked = function (inp) {
if (typeof inp != 'undefined')
inp = inp + ", through the grid directive.";
else
inp = "From the grid directive.";
$scope.ngButtonClick({ inp: inp });
};
}
};
})
and finally the ng-checkbox-column directive:
.directive("ngCheckboxColumn", function () {
return {
restrict: "A",
template: function (element, attributes) {
var htmlText = "<td><label><input type='checkbox' ng-model='item.checked' ng-click='tempButtonClicked()' /> From the checkbox directive.</label></td>";
return htmlText;
},
controller: function ($scope, $element) {
$scope.tempButtonClicked = function () {
var val = "From the checkbox directive";
$scope.buttonClicked(val);
};
}
};
})
My data collections are pretty straight forward:
$scope.headings = {
head1: 'Heading 1',
head2: 'Heading 2',
head3: 'Heading 3'
};
$scope.entries = [{
col1: 'Obj1.col1',
col2: 'Obj1.col2',
col3: 'Obj1.col3',
checked: false
}, {
col1: 'Obj2.col1',
col2: 'Obj2.col2',
col3: 'Obj2.col3',
checked: false
}, {
col1: 'Obj3.col1',
col2: 'Obj3.col2',
col3: 'Obj3.col3',
checked: false
}, {
col1: 'Obj4.col1',
col2: 'Obj4.col2',
col3: 'Obj4.col3',
checked: false
}];
This is still not entirely completed, but you should get the basic idea.

Related

Contidional template - Controller 'mdRadioGroup', required by directive 'mdRadioButton', can't be found

I'm trying to build custom directive that will allow me to display questions in survey. Because I have multiple types of questions I thought about creating single directive and change it's template based on question type.
my directive:
directive('question', function($compile) {
var combo = '<div>COMBO - {{content.text}}</div>';
var radio = [
'<div>RADIO - {{content.text}}<br/>',
'<md-radio-group layout="row" ng-model="content.answer">',
'<md-radio-button ng-repeat="a in content.answers track by $index" ng-value="a.text" class="md-primary">{{a.text}}</md-radio-button>',
'</md-radio-group>',
'</div>'
].join('');
var input = [
'<div>INPUT - {{content.text}}<br/>',
'<md-input-container>',
'<input type="text" ng-model="content.answer" aria-label="{{content.text}}" required md-maxlength="10">',
'</md-input-container>',
'</div>'
].join('');
var getTemplate = function(contentType) {
var template = '';
switch (contentType) {
case 'combo':
template = combo;
break;
case 'radio':
template = radio;
break;
case 'input':
template = input;
break;
}
return template;
}
var linker = function(scope, element, attrs) {
scope.$watch('content', function() {
element.html(getTemplate(scope.content.type))
$compile(element.contents())(scope);
});
}
return {
//require: ['^^?mdRadioGroup','^^?mdRadioButton'],
restrict: "E",
link: linker,
scope: {
content: '='
}
};
})
Inside my main controller I have list of questions and after clicking button I'm setting current question that is assign to my directive.
Everything works fine for first questions, but after I set current question to radio type I get this error:
Error: [$compile:ctreq] Controller 'mdRadioGroup', required by
directive 'mdRadioButton', can't be found!
I've tried adding required to my directive as below, but it didn't helped.
require: ['^mdRadioGroup'],
I can't figure out whats going on, because I'm still new to angular.
I've created Plunker to show my issue: http://plnkr.co/edit/t0HJY51Mxg3wvvWrBQgv?p=preview
Steps to reproduce this error:
Open Plunker
Click Next button two times (to navigate to question 3)
See error in console
EDIT:
I've edited my Plunker so my questions model is visible. I'm able to select answers, even in questions that throw error-questions model is updating. But still I get error when going to question 3.
I'd just simply extend a base directive, and then have a specialized ones with different directive names too.
// <div b></div>
ui.directive('a', ... )
myApp.directive('b', function(aDirective){
return angular.extend({}, aDirective[0], { templateUrl: 'newTemplate.html' });
});
Code taken from https://github.com/angular/angular.js/wiki/Understanding-Directives#specialized-the-directive-configuration
Working Demo
There is no need to create and use a directive for your requirement.
You can just use angular templates and ng-include with condition.
You can just create three templates (each for combo, radio and input) on your page like this,
<script type="text/ng-template" id="combo">
<div>COMBO - {{content.text}}</div>
</script>
And include these templates in a div using ng-include.
<!-- Include question template based on the question -->
<div ng-include="getQuestionTemplate(question)">
Here, getQuestionTemplate() will return the id of the template which should be included in this div.
// return id of the template to be included on the html
$scope.getQuestionTemplate = function(content){
if(content.type == "combo"){
return 'combo';
}
else if (content.type == "radio"){
return 'radio';
}
else{
return 'input';
}
}
That's all. You are done.
Please feel free to ask me any doubt on this.
In case anyone is wondering, the problem is that the parent component's scope is used to compile each new element. Even when the element is removed, bindings on that scope still remain (unless overwritten), which may cause the errors OP saw (or even worse, memory leaks).
This is why one should take care of cleaning up when manipulating an element's HTML content imperatively, like this. And because this is tricky to get right, it is generally discouraged to do it. Most usecases should be covered by the built-in directives (e.g. ngSwitch for OP's case), which take care of cleaning up after themselves.
But you can get away with manually cleaning up in a simplified scenario (like the one here). In its simplest form, it involves creating a new child scope for each compiled content and destroying it once that content is removed.
Here is what it took to fix OP's plunker:
before
scope.$watch('content', function () {
element.html(getTemplate(scope.content.type));
$compile(element.contents())(scope);
});
after
var childScope;
scope.$watch('content', function () {
if (childScope) childScope.$destroy();
childScope = scope.$new();
element.html(getTemplate(scope.content.type));
$compile(element.contents())(childScope);
});
Here is the fixed version.
I played a little with your code and find that, the reason why the error occurred is because the 3rd question got more answers than the 2nd, so when you create the mdRadioGroup the first time it defines 4 $index answers and later for question 3 it go out of bound with 6 answers... So a non elegant solution is to create as many $index as the max answers to any question, the first time, show only the ones with text...
.directive('question', function($compile) {
var combo = '<div>COMBO - {{content.text}}</div>';
var radio = [
'<div>RADIO - {{content.text}}<br/>',
'<md-radio-group layout="row">',
'<md-radio-button ng-repeat="a in content.answers track by $index" ng-show={{a.text!=""}} value="{{a.text}}" class="md-primary">{{a.text}}</md-radio-button>',
'</md-radio-group>',
'</div>'
].join('');
var input = [
'<div>INPUT - {{content.text}}<br/>',
'<md-input-container>',
'<input type="text" ng-model="color" aria-label="{{content.text}}" required md-maxlength="10">',
'</md-input-container>',
'</div>'
].join('');
var getTemplate = function(contentType) {
var template = '';
switch (contentType) {
case 'combo':
template = combo;
break;
case 'radio':
template = radio;
break;
case 'input':
template = input;
break;
}
return template;
}
then change questions to have the max amount of answers every time in all questions:
$scope.questions = [{
type: 'radio',
text: 'Question 1',
answers: [{
text: '1A'
}, {
text: '1B'
}, {
text: '1C'
}, {
text: ''
}, {
text: ''
}, {
text: ''
}, {
text: ''
}]
}, {
type: 'input',
text: 'Question 2',
answers: [{
text: '2A'
}, {
text: '2B'
}, {
text: '2C'
}, {
text: ''
}, {
text: ''
}, {
text: ''
}, {
text: ''
}]
}, {
type: 'radio',
text: 'Question 3',
answers: [{
text: '3A'
}, {
text: '3B'
}, {
text: '3C'
}, {
text: '3D'
}, {
text: ''
}, {
text: ''
}, {
text: ''
}]
}, {
type: 'combo',
text: 'Question 4',
answers: [{
text: '4A'
}, {
text: '4B'
}, {
text: ''
}, {
text: ''
}, {
text: ''
}, {
text: ''
}, {
text: ''
}]
}];
The rest of the code is the same.
As I say before, no elegant and for sure there are better options, but could be a solution for now...

Passing array as directive parameter

Supposedly, I have a directive with scope
scope: {
columns: '=',
}
How can I achieve this?
<my-directive columns="[{ field:'id', displayName: 'ID' },
{ field:'title', displayName: 'Title' },
{ field:'address', displayName: 'Address' },
{ field:'city', displayName: 'City' }]" />
Apparently Angular's compiler have problems with figuring out that it's an array, although it doesn't have a problem with standard JS objects passed this way - {}. Is there any nifty way to do this? Does the fact that it works with objects is just a coincidence?
Please keep in mind that I know, that I can set this as $scope parameter in Controller and pass just the parameter name from $scope. But I would really like to learn if it's possible to do it straight from HTML.
--
Update with full code:
This is how it is used in template
<es-paged-data-grid columns="[
{ field:'id', displayName: 'ID' },
{ field:'title', displayName: 'Title' },
{ field:'address', displayName: 'Address' },
{ field:'city', displayName: 'City' }
]">
</es-paged-data-grid>
This is the directive:
app.directive('esPagedDataGrid', function () {
var definition = {
restrict: "E",
replace: false,
transclude: true,
scope: {
columns: '=',
},
templateUrl: 'application/directive/pagedDataGrid/PagedDataGrid.html',
controller: ['$scope', '$element', '$attrs', '$transclude', function($scope, $element, $attrs, $transclude) {
var dataGridOptions = {};
if ($scope.columns) {
dataGridOptions.columnDefs = $scope.columns;
}
$scope.dataGridOptions = dataGridOptions;
}]
};
return definition;
});
This is the directive's template:
<div ng-grid="dataGridOptions">
</div>
You have mentioned in one of your comments that your directive throws a nonassign error. A nonassign error occurs when a directive attempts to modify an isolate scope defined using the = notation wherein the assigned attribute is an expression that is not two-way data bound(not a scope variable).
Probably somewhere in your directive, you may have attempted to change scope.columns directly such as, scope.columns = []; or any other scope property that is assigned via the = notation. Try removing that and it might solve your problem.
UPDATE:
trying changing this:
dataGridOptions.columnDefs = $scope.columns;
to this:
dataGridOptions.columnDefs = angular.copy($scope.columns);
I suspect the ng-grid directive is probably doing some manipulation on the columnDefs options, since columnDefs has a direct reference towards $scope.columns property then any manipulation performed in the columnDefs options would likely affect $scope.columns.
Well AFAIK i guess a way to initialize data in DOM is using the ng-init directive.
So the directive can look like,
app.directive('testd', function() {
return {
scope: {
options: "=ngInit"
},
link: function(scope, e, a) {
console.log('test', scope.options);
},
template: 'test'
};
});
And you can supply the array via ng-init,
<div testd ng-init="[{ field:'id', displayName: 'ID' },
{ field:'title', displayName: 'Title' },
{ field:'address', displayName: 'Address' },
{ field:'city', displayName: 'City' }]"></div>
Sample Demo: http://plnkr.co/edit/MwOPLm16KTOr2Q3rY6LK?p=preview
Well one more way would be to pass it via an attribute. Though it will be taken as string, you can use eval() to convert it to object and assign it to a scope variable. Plnkr is also updated for the same. Included columns:"#" and use eval(attrs.columns) to convert it to array

Angular: compiling template from string to string

See the fiddle
test.directive('testMe', ['$compile', function($compile) {
return {
restrict: 'EA',
transcluded: true,
link: function(scope, element, attrs) {
scope.state = 'compiled';
//a = $(element.html()); //this gives: Error: Syntax error, unrecognized expression: Something I actually want to save {{state}}
a = $('<div>' + element.html() + '</div>');
var el = $compile(a)(scope);
scope.compiled = element.html();
},
}
}]);
For some reason I want to compile template with a given scope to a string, and after asking Uncle Google and doing some experiments I gave up.
Does annyone knows how to do this? Maybe my approach is wrong at all?
I want to notice that as a result I need template compiled to string, saved in a variable.
EDIT
To be more specific, here's what i need:
var template = "<p>{{variable}}</p>";
$scope.variable = "test";
var compiled = //magic goes here
alert(compiled); // gives <p>test</p>
I recently stumbled onto a similar problem and after several hours i was able to solve it with a little help from this post:
Blog post from Ben Lesh
I wanted to create a ComboBox to select an Image for another Entity to save in a relational Database. Of course my Entity had other relations too and so I described them in a JSON-File
//image
{ id: 4, path: 'http://www.example.com/myimage.png', name: 'Picture of my cat' }
//entity
{ id: 7, foo: 'bar', imageId: 4, anotherEntityId: 12}
//anotherEntity
{ id: 12, name: 'My Entity'}
I now wanted to create a Formular for entering new entities and for the foreign keys I wanted a combobox
I then declared another JSON-Object, containing every column of entity and also how i wanted them rendered
{cols:
[
{
name: 'imageId',
displayName: 'Image',
type: Image,
template: '<img src="{{e.path}}" />{{e.name}}'
},
...
]}
To do so i created a new directive, called nComboBoxRenderer
<ng-combo-box-renderer data="image", template="column.template"></ng-combo-box-renderer>
-
directives.directive('ngComboBoxRenderer', ['$compile', function($compile) {
return {
restrict: "E",
scope: {
e: '=data', // in my case this is the image
template: '=template' // the template
},
link: function($scope, element, attributes) {
var tl = angular.element("<span>" + $scope.template + "</span>");
compiled = $compile(tl);
element.append(tl);
compiled($scope);
}
}
}]);
While this is not the exact same use case as you have, the process involved appears to be the same.

AngularJS ng-repeat rerender

I'm building a simple app with AngularJS. The app make a async AJAX call to the server and the server returns an array like this:
{
paragraphs: [
{content: "content one"},
{content: "cnt two"},
{content: "random three"},
{content: "last one yeeaah"}
]
}
So I'm setting this content to the StorageService factory via my set method. Everything is fine here.
I'm using ng-repeat to render the results and JQuery UI sortable to be able to change the order of the elements. When an item is swapped my script is calling the StorageService.swap method and the element order in StorageService is updated, BUT ng-repeat isn't rerendering the change, but if I remove/add or change the content it's working. How I can force the angular to rerender the ng-repeat?
= JSFIDDLE =
http://jsfiddle.net/6Jzx4/3/
= Example =
When a swap occurs the ng-repeat should be rerendered, so the IDs are consecutive
= Code =
HTML
<div ng-controller="Test" sortable>
<div ng-repeat="item in data().paragraphs" class="box slide_content" id="{{$index}}">
{{item.content}}, ID: {{$index}}
</div>
<input type="button" ng-click="add()" value="Add">
</div>
JS
var App = angular.module("MyApp", []);
App.controller("Test", function($scope, StorageService) {
StorageService.set({
paragraphs: [
{content: "content one"},
{content: "cnt two"},
{content: "random three"},
{content: "last one yeeaah"}
]
});
$scope.data = StorageService.get;
$scope.add = StorageService.add;
});
App.directive("sortable", function(StorageService) {
return {
link: function(scope, element, attrs) {
$(element[0]).sortable({
cancel: ".disabled",
items: "> .slide_content:not(.disabled)",
start: function(e, t) {
t.item.data("start_pos", t.item.index());
},
stop: function(e, t) {
var r = t.item.data("start_pos");
if (r != t.item.index()) {
StorageService.sort($(this).sortable("toArray"));
}
}
});
}
};
});
App.factory('StorageService', function() {
var output = {};
return {
set: function(data) {
angular.copy(data, output);
return output;
},
get: function() {
return output;
},
add: function() {
output.paragraphs.push({
content: 'Content'
});
},
sort: function(order) {
var localOutput = [];
for (var j in order) {
var id = parseInt(order[j]);
localOutput.push(output.paragraphs[id]);
}
console.log('new order', localOutput);
output.paragraphs = localOutput;
return output;
}
};
});
Angular doesn't know you've changed the array. Executing your sort inside a scope.$apply() will address that.
Note that I've added a that variable since this changes meaning inside the apply.
var that = this;
scope.$apply(function() {
StorageService.sort($(that).sortable("toArray"));
}
But that fix uncovers other problems that appear to be caused by the interaction between the jQuery sortable and Angular (here's a fiddle that shows an attempt at resolving the problems but still has issues). These issues have been solved in Angular UI Sortable. So a good path forward may be to switch to this.

Using dynamicaly specified ng directives within my custom directive

I am attempting to create a custom table element like this:
<datatable items='tableItems' columns='columnsConfig' />
Here 'tableItems' is my array of items and 'columnsConfig' is configuration for column rendering, something like :
$scope.tableItems = [...];
$scope.columnsConfig = [
{
name: 'check',
with: '20px',
renderer: function (rowItem, cellValue) {
return '<input ng-click="clickHandler()" type="checkbox"/>';
}
},
{name: "person.fullName", text: "Name", visible: true, width: '150px'},
{
name: "person.age",
text: "Age",
renderer: function(rowItem, cellValue) {
return cellValue + ' years old';
}
}
];
Inside renderer function I can specify some additional data processing or templating.
In my directive template I have this:
<tbody>
<tr ng-repeat="item in items">
<td ng-repeat="column in columns"
ng-show="column.visible"
ng-bind-html-unsafe="getCellValue(item, $index)">
</td>
</tr>
</tbody>
where inside 'getCellValue' function I invoking my renderer function. Here is directive code:
angular.module('components', [])
.directive('datatable', function () {
return {
restrict: 'E',
templateUrl: '../pages/component/datatable.html',
scope: {
items: "=",
columns: "="
},
controller: function ($scope, $element) {
$scope.getCellValue = function (item, columnIndex) {
var column = $scope.columns[columnIndex];
// return render function result if it has been defined
if (column.renderer) {
return column.renderer(item, getItemValueByColumnName(item, column.name));
}
// return item value by column
return getItemValueByColumnName(item, column.name);
};
}
}
});
All works fine except ng-... directives. I think I have to do some additional processing of 'renderer' function results via $compile or something but yet I can't figure out how to achieve this. So the question is how make ng directives work when I specify them via my renderer function ?
Thanks.
After some investigations I have found next solution:
//after all DOM manipulations we should recompile parts that has been modified
setTimeout(function () {
applyAfterRenderDOMChanges();
}, 0);
var applyAfterRenderDOMChanges = function () {
var cells = $('td', element).children();
$compile(cells)(scope);
scope.$apply();
};
I have some concerns about efficiency of this solition but it works well so far.

Categories