N level hierarchy Array Angular JS - javascript

I'm writting a questionnaires application, there are questions with responses, those responses can have child questions, these questions might have responses with another child questions then being a N level hierarchy, I need to find the best strategy to load this in a html list, using the normal ng-repeat I have a limit of level, in this example I chain 4 levels, but it could be more than that, I appreciate any comment or suggestion.
var myApp = angular.module('myApp',[]);
myApp.controller('myCtrl',function ($scope){
$scope.questionnaire = [
{
QuestionID: 1,
Description: "Question 1",
Responses: [{
RespDescription: "Response 1"
},
{
RespDescription: "Response 2",
ChildQuestions: [{
QuestionID: 2,
Description: "Child Question 2.1",
Responses: [{
RespDescription: "Child Response 2.1.1"
},
{
RespDescription: "Child Response 2.1.2",
ChildQuestions: [{
QuestionID: 3,
Description: "Child Question 2.1.2.1",
Responses:[{
RespDescription: "Child Response...",
ChildQuestions:[{
QuestionID:4,
Description: "Other Child Question",
Responses:[{
RespDescription: "Response..."
}]
}]
}]
}]
}]
}]
}]
}
];
})

I've done a similar questionnaire type app with a structure like that. What I did was to do is create a back-end api that has a tree-like structure of relations.
You want this to be hooked into a back end and not just written out, because otherwise it could get incredibly messy, a lot like a callback hell.
Here is the starting of the project on github. It uses loopback to do the data-modeling and hooks into an angular front-end, but you can use a back-end any way you like.
The idea is that when you query a first question, it has a few child answers. Each of those answers then has another question attached to it, and so on, and so on. The relationships of each model are whats important here.
This way you can create a controller that when you select a answerC to questionA, it would query the database for the related questionC object, and include all answers linked that that new questionC.
You would then add the newly loaded questionC with its answers to the main array of questions and scroll down (or something like that).
A quick sudo code example:
//controller.js
app.controller('questionair', function(Answer, Question){
//Lets load our first question, with the related 3 answers
Question.findById({id: 1}, {include: 'answers'}).$promise
.then(function(question){
$scope.questions = [question];
});
//function that gets new question from our select answer
$scope.answerMe = function(questionId){
Question.findById({id: questionId}, {include: 'answers'}).$promise
.then(function(newQuestion){
$scope.questions.push(newQuestion);
},function(error){
console.log('You\'ve answered the last question!');
});
};
});
//index.html
<div ng-repeat="question in questions">
<h2>{{ question.text }}</h2>
<ul>
<li ng-repeat="answer in question.answers"
ng-click="answerMe(answer.questionId)">
{{ answer.text }}
</li>
</ul>
</div>

I was through Mark Lagendijk's code in plunker and He had the solution for this task, recursivity is the secret,with a directive calling itself is possible to represent a N levels estructure, The key is the service called RecursionHelper that compile and avoid the infinite loop in the directive, I adapted the code to my necessity and this is the result:
RecursionHelper
/*
* An Angular service which helps with creating recursive directives.
* #author Mark Lagendijk
* #license MIT
*/
angular.module('RecursionHelper', []).factory('RecursionHelper', ['$compile', function($compile){
return {
/**
* Manually compiles the element, fixing the recursion loop.
* #param element
* #param [link] A post-link function, or an object with function(s) registered via pre and post properties.
* #returns An object containing the linking functions.
*/
compile: function(element, link){
// Normalize the link parameter
if(angular.isFunction(link)){
link = { post: link };
}
// Break the recursion loop by removing the contents
var contents = element.contents().remove();
var compiledContents;
return {
pre: (link && link.pre) ? link.pre : null,
/**
* Compiles and re-adds the contents
*/
post: function(scope, element){
// Compile the contents
if(!compiledContents){
compiledContents = $compile(contents);
}
// Re-add the compiled contents to the element
compiledContents(scope, function(clone){
element.append(clone);
});
// Call the post-linking function, if any
if(link && link.post){
link.post.apply(null, arguments);
}
}
};
}
};
}]);
questionTree Directive :
directives.directive('questionTree', function (RecursionHelper) {
return {
restrict: "AE",
scope: {
items: "=",
},
priority: 500,
replace: true,
//I use templateURL but for simplicity I used inline template in this code
template: function (el, attr) {
var itemType = attr["itemType"];
if (itemType == "question") {
return '<ul>'+
'<li ng-repeat="item in items">'+
'<div ng- click="loadChildResponses(item);$event.stopPropagation();">{{item.Description}}</div>'+
'<question-tree items="item.Responses" item-type="reponse"></question-tree>'+
'</li>'+
'</ul>';
}
else {
return '<ul>'+
'<li ng-repeat="item in items">'+
'<div ng-click="loadChildQuestions(item);$event.stopPropagation();">{{item.Description}}</div>'+
'<question-tree items="item.ModelWizardQuestions" item-type="question"></question-tree>'+
'</li>'+
'</ul>';
}
},
controller: function ($scope, $http) {
$scope.loadChildResponses = function (item) {
$http.get(siteUrls.GetReponses + "?QuestionID=" + item.QuestionID)
.success(function (data) {
if (data && data.length > 0) {
item.Responses = data;
}
});
};
$scope.loadChildQuestions = function (item) {
$http.get(siteUrls.getChildQuestions + "?ResponseID=" + item.ResponseID)
.success(function (data) {
if (data && data.length > 0) {
item.Questions = data;
}
});
};
},
compile: function (element) {
// Use the compile function from the RecursionHelper,
// And return the linking function(s) which it returns
return RecursionHelper.compile(element);
}
}
});
So, I load the first level of the questions, and attach the questionTree directive, and the application is able to load N levels.
The HTML:
<ul>
<li ng-repeat="question in Questions">{{question.Description}}
<ul>
<li ng-repeat="response in question.Responses"><span>{{response.Description}}</span>
<question-tree items="response.Questions" item-type="question"></question-tree>
</li>
</ul>
</li>
</ul>

Related

Angularjs: Dynamically load directives in view with data

I am trying to make a dynamic framework with angularjs. I load sections with a webapi, that have data about the directive that it uses and the data that should be used in that directive. The data that I send can look like this:
[
{
id: "section1",
directive: "<my-directive></my-directive>",
content: {
title: "This is a title",
text: "This is a text"
}
},
{
id: "section2",
directive: "<my-other></my-other>",
content: {
title: "This is another title",
list: ["This is a text", "This is another text"]
}
}
]
When I load this data, I convert the directives in to element with $compile.
angular.forEach($sections, (value, key):void => {
value.directive = $compile(value.directive)($scope);
}
So I can actually load this data in the view, like this:
<div ng-repeat="section in sections">
{{section.directive}}
</div>
First of all, this doesn't show up in my view, so how do I fix this?
Then the second issue I have. When I actually get this directive loaded into the view, how will I be able to access the data that should be used in this directive? I do have an id added to the sections.This is what I tried:
angular.forEach($sections, (value, key):void => {
value.directive = $compile(value.directive)($scope);
var parent = angular.element('#sectionsparent'); //The parent element has this id
parent.append(value.directive);
}
This way the section elements show up, but I cannot access the data that should be loaded inside the directive.
Thank you for your help in advance, let me know if you need more information.
EDIT:
When the directive is eventually loaded, I want to be able to access the data that belongs to that section. So if we take first section in the sample data, I want to be able to do the following in the template of the directive:
<!-- This file is myDirectiveTemplate.hmtl -->
<div id="{{id}}>
<h1>{{title}}</h1>
<p>{{text}}</p>
</div>
I don't care if I have to access these properties through a viewmodel object, so it would be {{vm.id}} instead of {{id}}. But I prefer to not have any function calls inside my template to actually get data.
Alright. There may be another way to accomplish this, or perhaps using includes instead of directives, but here's one way at least.
Taking your example code, you can follow your second route with $compile and append but you also need to pass an html-attribute for the isolate scope's content and bind it with a new $scope with the section added. (You also need to wrap in a $timeout so querying the DOM happens after it's initially rendered).
var app = angular.module('app', []);
app.controller('AppCtrl', function($scope, $compile, $timeout) {
$scope.sections = [
{
id: "section1",
directive: "my-directive",
content: {
title: "This is a title",
text: "This is a text"
}
},
{
id: "section2",
directive: "my-other",
content: {
title: "This is another title",
list: ["This is a text", "This is another text"]
}
}
];
// Need to timeout so rendering occurs and we can query the DOM.
$timeout(() => {
angular.forEach($scope.sections, (section) => {
let newScope = $scope.$new();
newScope.content = section.content;
let dir = section.directive;
let compiled = $compile(`<${dir} content="content"></${dir}>`)(newScope);
let parent = angular.element(document.querySelector('#' + section.id));
parent.append(compiled);
});
});
});
app.directive('myDirective', function() {
return {
restrict: 'E',
scope: {content: '='},
template: `<div>
<h1>{{content.title}}</h1>
<p>{{content.text}}</p>
</div>`,
};
});
app.directive('myOther', function() {
return {
restrict: 'E',
scope: {content: '='},
template: `<div>
<h1>{{content.title}}</h1>
<ul>
<li ng-repeat="item in content.list">{{item}}</li>
</ul>
</div>`,
};
});
<script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.2.23/angular.min.js"></script>
<div ng-app="app" ng-controller="AppCtrl">
<div ng-repeat="section in sections" id="{{section.id}}"></div>
</div>

Angular select using ng-options and ng-selected

I am having an issue with a select. I have a profile view that utilizes the profileCtrl. In that controller I get the users info from the db and put it into scope. I also use a service to grab all the info from the config table in the db and insert that into rootScope. My question and answers come from the config table (rootScope) the user's selected answer comes from the user info (scope). I need a select that preselects whatever answer the user has in the db. Below is my code.
Profile Controller:
app.controller('profileCtrl', function ($scope, $log, $http, $timeout, Data, Auth, dataShare, $sessionStorage, $rootScope, $confirm) {
$timeout(function() {
// get user's info from db and put it into scope
Data.get('profile/'+$rootScope.user.uid).then(function(data){
$scope.profs = data.data;
$scope.buttonText = 'Update Profile';
});
}, 100);
// get the configs from the configs service and put it in the rootScope
dataShare.getconfigs().then(function(data){
$rootScope.configs = data;
// get the answers from the config table for the select's options
$scope.availableAnswers = [
{ answer: $rootScope.configs[0].a1 },
{ answer: $rootScope.configs[0].a2 },
{ answer: $rootScope.configs[0].a3 },
{ answer: $rootScope.configs[0].a4 },
{ answer: $rootScope.configs[0].a5 }
];
});
// function executed on change from the select
$scope.selectedItemChanged = function() {
$log.log($scope.selectedAnswer);
}
// inline edit title
$scope.updateUser = function(data) {
Data.put('config/'+data.id, {profile_page_title:data.profile_page_title}).then(function (result) {
Data.toast(result);
});
};
$scope.saveProfile = function (profile) {
profile.roles = $rootScope.user.roles;
if(profile.uid.length > 0){
Data.put('profile/'+profile.uid, profile).then(function (result) {
$sessionStorage.user = profile;
$rootScope.user = $sessionStorage.user;
Data.toast(result);
});
}else{
Data.post('profile', profile).then(function (result) {
$rootScope.name = profile.name
Data.toast(result);
});
}
};
});
HTML: (I have condensed the code to be read easily)
<section class="row" id="" ng-repeat="profile in profs">
<div class="col-xs-12" id="questionWidget">
<h4>{{configs[0].question}}</h4>
<!-- user's answer from db -->
{{profs[0].answer}}
<select ng-model="selectedAnswer" ng-change="selectedItemChanged()" ng-options="a.answer for a in availableAnswers">
</select>
</div>
</section>
Alright, so I made a plunk, and this is what I came up with. This will set the select option if the professor's current answer exists in the list.
The reason ng-init didn't work is because the selectedAnswer model is actually expecting to see an object with property 'answer'. In other words, selectedAnswer is the entire object, not just the answer itself.
https://plnkr.co/edit/CRraZXY2jsmiV1oJx6VN?p=preview
$scope.profs = [
{ answer: 'answer2' }
]
$scope.availableAnswers = [
{ answer: 'answer1' },
{ answer: 'answer2' },
{ answer: 'answer3' },
];
angular.forEach($scope.availableAnswers, function(availableAnswer){
if ($scope.profs[0].answer === availableAnswer.answer)
$scope.selectedAnswer = availableAnswer
});
-- Old Answer --
Have you tried ng-init?
ng-init="selectedAnswer = profs[0].answer"
I have made a very complex plunkr, I was a bit slower than #brianslattery I guess.
Here's my take:
https://plnkr.co/edit/5fnB6oWrZ9WhujN7eH0T?p=preview
<section class="row" id="" ng-repeat="profile in profs">
<div class="col-xs-12" id="questionWidget">
<h4>{{configs[0].question}}</h4>
<!-- user's answer from db -->
Answer: {{profile.answer}}<br>
<select name="selector" ng-model="profile.answer" ng-change="selectedItemChanged(profile)" ng-options="a as a.answer for a in availableAnswers track by a.answer">
</select>
</div>
</section>
The app will look something like this:
var app = angular.module('plunker', []);
app.controller('MainCtrl', function($rootScope, $scope, $timeout, $log, $filter) {
$timeout(function() {
// get user's info from db and put it into scope
$scope.profs = [ { name: 'prof1', answer: 'a3' }, { name: 'prof2', answer: 'a2' } ];
$scope.buttonText = 'Update Profile';
appendAnswerObjects();
}, 100);
// get the configs from the configs service and put it in the rootScope
$timeout(function(data){
$rootScope.configs = [ { question: 'Question?', a1: 'a1', a2: 'a2', a3: 'a3', a4: 'a4', a5: 'a5' } ];
// get the answers from the config table for the select's options
$scope.availableAnswers = [
{ answer: $rootScope.configs[0].a1 },
{ answer: $rootScope.configs[0].a2 },
{ answer: $rootScope.configs[0].a3 },
{ answer: $rootScope.configs[0].a4 },
{ answer: $rootScope.configs[0].a5 }
];
appendAnswerObjects();
}, 200);
// function executed on change from the select
$scope.selectedItemChanged = function(profile) {
$log.log(profile);
}
function appendAnswerObjects() {
if($scope.profs && $scope.profs.length && $scope.availableAnswers && $scope.availableAnswers.length) {
$scope.profs.forEach(function(profile) {
profile.answer = $filter('filter')($scope.availableAnswers, { answer: profile.answer })[0];
});
}
}
});
There are several issues to consider. NgOptions likes to use objects rather than values. Also, your 'ng repeat' is calling for profs[0] instead of using the now repeated profiles variable.
Other issue to consider is that your 'answer' would not be 'profile dependent', but will be one for the entire scope, I'm pretty sure that is NOT what you want.
So, the plunkr I created makes individual answers for each. It also takes into account the possibility of getting either the profiles first or the available answers first, no matter which comes first, which is, I believe, Important.
Let me know if this helps.
Best regards,
Rafa.

AngularJS ng-repeat custom directive

<div>
<ul id="teachers">
<li ng-repeat></li>
</ul>
<ul id="students">
<li ng-repeat></li>
</ul>
</div>
I have two ul elements and dynamic data. For example:
[
{
name: 'Jack'
status: 'teacher'
},
{
name: 'Angelina'
status: 'teacher'
},
{
name: 'Maya'
status: 'student'
},
{
name: 'Kate'
status: 'teacher'
},
{
name: 'Margaret'
status: 'student'
}
]
I want to write some custom directive for ng-repeat, which will generates lists, for students and for teachers, for different ul's.
How can I write directive, with some condition, which will repeat li's in the right ul?
Yes, I can, filter My data and generate two Arrays, for students and teachers and than repeat those Independently.
But, I don't like this way. How it is possible to write one custom directive which will determines, where to repeat current Object?
UPDATE
Okey, I'm new in angular, so I've thought, that there will be something simple trick, something like this:
if(status === 'something')
use this template
else
use this template
So, with your answers I could write conditions which I wanted. Sorry about this, this was stupid decision.
So my actual case is:
I have Breadcrumbs data and main container, which width is equal to 500px.
I want to repeat li in this container and I want to my li's were always always inline.
If my data will be big, or some title will be big and my ul width will be more, than my container, some li elements will be dropped bellow.
because of this, I have two ul elements and lis which won't have there space I want to insert in second ul, which will be hidden and after click on something I will show this ul
Options:
Use in built angular filters. For example:
<ul id="teachers">
<li ng-repeat="person in people | filter: { status: 'teacher' }"></li>
</ul>
plnkr
Split the array in your controller. Both split arrays should still point to the original object (in the original array), so manipulation should be ok.
You can definitely create your own directive, but you will end up encapsulating one of the options above.
Better than write a directive, filter your array javascript with the built-in functions for array.
Example:
HTML
<div ng-controller="ClassroomController as classroom">
<ul id="teachers">
<li ng-repeat="teacher in classroom.teachers track by $index"></li>
</ul>
<ul id="students">
<li ng-repeat="student in classroom.students track by $index"></li>
</ul>
</div>
JAVASCRIPT
function Controller() {
var vm = this;
vm.data = [
{
name: 'Jack'
status: 'teacher'
},
{
name: 'Angelina'
status: 'teacher'
},
{
name: 'Maya'
status: 'student'
},
{
name: 'Kate'
status: 'teacher'
},
{
name: 'Margaret'
status: 'student'
}
];
vm.teachers = vm.data.filter(function(item){return item.status === 'teacher'});
vm.students = vm.data.filter(function(item){return item.status === 'student'});
}
I also think that filtering is the best as already answered. But according to your update you can do something like this in yuor directive controller:
$scope.getTemplateUrl = function() {
if (status == something) {
return '/partials/template1.html';
} else {
return '/partials/template2.html';
}
}
Then define your directive template as follows:
template: '<ng-include src="getTemplateUrl()"/>',
Of course status has to be defined before the directive is rendered.
directive('info', function()
{
return {
restrict : 'E',
template : '<ul> <li ng-repeat="l in list"><div ng-if="check(l)">{{l.name}}</div></li></ul></br><ul><li ng-repeat="l in list"><div ng-if="!check(l)">{{l.name}}</div></li></ul>',
controller : function($scope)
{
$scope.check = function(l)
{
if(l.status=="student")
return true;
else if(l.status=="teacher")
return false;
}
}
};
});

AngularJS Dynamic Filter Configuration fail to resolve

I'm a newbie to AngularJS with some fair knowledge with KnockoutJS also.
I'm trying to implement a search feature on 'products' in my ViewModel that is configurable by the end user by combining..
Search by 'name' of product
Search by 'tags' of product
in combination with search operations
CONTAINS
STARTS WITH
EQUALS
I believe you understood the functionality I am trying to build up.
The following is the ViewModel I'm using.
var InstantSearchController = function ($scope) {
var self = this;
$scope.filtersAvailable = [
{
displayText: 'Tag',
filterMethod: 'tagFilter',
description: 'Filter by Tags'
},
{
displayText: 'Description',
filterMethod: 'descriptionFilter',
description: 'Filter by description'
}
];
$scope.selectedFilter = $scope.filtersAvailable[1];
$scope.filterBehaviorsAvailable = [
{
displayText: 'CONTAINS',
regexPrefix: '',
regexPostfix: ''
},
{
displayText: 'STARTS WITH',
regexPrefix: '^',
regexPostfix: ''
},
{
displayText: 'EQUALS',
regexPrefix: '^',
regexPostfix: '$'
}
];
$scope.selectedFilterBehavior = $scope.filterBehaviorsAvailable[0];
$scope.products = [
{
name: 'Household Product',
description: 'Description household',
tags: ['personal', 'home']
},
{
name: 'Office product',
description: 'Business equipments',
tags: ['office', 'operations', 'business']
},
{
name: 'Misc products',
description: 'Uncategorized items',
tags: ['noclass']
}
];
}
Now, the following is my filters list.
var app = angular.module('InstantSearchModule', []);
//FILTERS BEGIN
app.filter('descriptionFilter', function () {
var filterFunction = function (data, filterBy) {
if (filterBy == null || filterBy === '')
return data;
var filtered = [];
var regExp = new RegExp(filterBy, 'gi');
angular.forEach(data, function (item) {
if (item.description.match(regExp))
filtered.push(item);
});
return filtered;
};
return filterFunction;
});
app.filter('tagFilter', function () {
var tagFilter = function (data, filterBy) {
if (filterBy == null || filterBy === '')
return data;
var filtered = [];
var regExp = new RegExp('^' + filterBy, 'gi');
debugger;
angular.forEach(data, function (item) {
var isMatching = false;
angular.forEach(item.tags, function (t) {
isMatching = isMatching || (t.match(regExp) != null);
});
if (isMatching)
filtered.push(item);
});
return filtered;
};
return tagFilter;
});
// FILTERS END
I have created a working part to configure search criteria including the 'filterString'(in a textbox), search operand[tags or description](with a select list) and a search mode[starts with / contains / equals](with another select list). Both of the filters are working fine if I specify the filter functions (tagFilter or descriptionFilter) directly in AngularJS directives as follows [JSFiddle Here].
<div data-ng-repeat="p in products|tagFilter:filterString|orderBy:'description.length'">
<h4 style="margin-bottom: 5px">{{$index+1}}. {{p.name}}</h4>
<div>
{{p.description}}
<button data-ng-repeat="t in p.tags|orderBy:'toString()'">{{t}}</button>
</div>
</div>
I was expecting the following to work for me as {{selectedFilter.filterMethod}} is rendering the value successfully, but is showing an error. Please see the HTML I tried to use for it.JSFiddle Here
<div data-ng-repeat="p in products|{{selectedFilter.filterMethod}}:filterString|orderBy:'description.length'">
<h4 style="margin-bottom: 5px">{{$index+1}}. {{p.name}}</h4>
<div>
{{p.description}}
<button data-ng-repeat="t in p.tags|orderBy:'toString()'">{{t}}</button>
</div>
</div>
I have attached the error I'm receiving in Google Chrome developer tools along with the resultant HTML to the subject. Please see below.
As you can see in the HTML, the filter method is not resolved and so, its not working for me. Do you guys have an advice what I am doing wrong?
If I understand it correctly all you need is a way to dynamically change filters. Everything else seems to be working.
I dont think you can use the syntax you are trying to use but you can make a third filter that injects the two others and chooses the right one depending on the parameters you send in.
New filter:
app.filter('multiFilter', function (descriptionFilterFilter, tagFilterFilter) {
var filterFunction = function (data, filterBy, filterRegExp, selectedFilter) {
if(selectedFilter.displayText === 'Description') {
return descriptionFilterFilter(data, filterBy, filterRegExp);
}
else {
return tagFilterFilter(data, filterBy, filterRegExp);
}
};
return filterFunction;
});
As you can see it also takes the filterRegExp and the selectedFilter as parameters. I also changed your old filters to take selectedFilter as a parameter.
Also notice that you have to append "Filter" to the filter name in order to inject it.
You call the new filter like this
multiFilter:filterString:filterRegExp:selectedFilter
So the div could loke something like this
<div data-ng-repeat="p in products|multiFilter:filterString:filterRegExp:selectedFilter|orderBy:'description.length'"
title="{{selectedFilter.filterMethod}}">
<h4 style="margin-bottom: 5px">{{$index+1}}. {{p.name}}</h4>
<div>
I made a working fork of your fiddle
Your fiddle is not working and has other error but, the reason filters are not loading is that you have used global controller function and not registered with your app module for the injection to work. Your filter belong to module InstantSearchModule but you controller does not.
Try the module registration syntax
app.controller('InstantSearchController',function($scope) {
});
see the Angular guide on controller https://code.angularjs.org/1.2.15/docs/guide/controller
Update: As it turns out the issue is not with dependency injection. It is because you cannot use expression to dynamically change filter. When i set to fixed filter it works fine
<div data-ng-repeat="p in products|descriptionFilter:filterString|orderBy:'description.length'"
title="{{selectedFilter.filterMethod}}">
You would have to either combine then or find a way to do select filtering.
See my fix here
http://jsfiddle.net/cmyworld/pW9EZ/1/

Recursion in Angular directives

There are a couple of popular recursive angular directive Q&A's out there, which all come down to one of the following solutions:
manually incrementally 'compile' HTML based on runtime scope state
example 1 [stackoverflow]
example 2 [angular jsfiddles page]
don't use a directive at all, but a <script> template which refers to itself
example 1 [google groups]
The first one has the problem that you can't remove previously compiled code unless you comprehensibly manage the manual compile process. The second approach has the problem of... not being a directive and missing out on its powerful capabilities, but more urgently, it can't be parameterised the same way a directive can be; it's simply bound to a new controller instance.
I've been playing with manually doing an angular.bootstrap or #compile() in the link function, but that leaves me with the problem of manually keeping track of elements to remove and add.
Is there a good way to have a parameterized recursive pattern that manages adding/removing elements to reflect runtime state? That is to say, a tree with a add/delete node button and some input field whose value is passed down a node's child nodes. Perhaps a combination of the second approach with chained scopes (but I have no idea how to do this)?
Inspired by the solutions described in the thread mentioned by #dnc253, I abstracted the recursion functionality into a service.
module.factory('RecursionHelper', ['$compile', function($compile){
return {
/**
* Manually compiles the element, fixing the recursion loop.
* #param element
* #param [link] A post-link function, or an object with function(s) registered via pre and post properties.
* #returns An object containing the linking functions.
*/
compile: function(element, link){
// Normalize the link parameter
if(angular.isFunction(link)){
link = { post: link };
}
// Break the recursion loop by removing the contents
var contents = element.contents().remove();
var compiledContents;
return {
pre: (link && link.pre) ? link.pre : null,
/**
* Compiles and re-adds the contents
*/
post: function(scope, element){
// Compile the contents
if(!compiledContents){
compiledContents = $compile(contents);
}
// Re-add the compiled contents to the element
compiledContents(scope, function(clone){
element.append(clone);
});
// Call the post-linking function, if any
if(link && link.post){
link.post.apply(null, arguments);
}
}
};
}
};
}]);
Which is used as follows:
module.directive("tree", ["RecursionHelper", function(RecursionHelper) {
return {
restrict: "E",
scope: {family: '='},
template:
'<p>{{ family.name }}</p>'+
'<ul>' +
'<li ng-repeat="child in family.children">' +
'<tree family="child"></tree>' +
'</li>' +
'</ul>',
compile: function(element) {
// Use the compile function from the RecursionHelper,
// And return the linking function(s) which it returns
return RecursionHelper.compile(element);
}
};
}]);
See this Plunker for a demo.
I like this solution best because:
You don't need an special directive which makes your html less clean.
The recursion logic is abstracted away into the RecursionHelper service, so you keep your directives clean.
Update:
As of Angular 1.5.x, no more tricks are required, but works only with template, not with templateUrl
Manually adding elements and compiling them is definitely a perfect approach. If you use ng-repeat then you will not have to manually remove elements.
Demo: http://jsfiddle.net/KNM4q/113/
.directive('tree', function ($compile) {
return {
restrict: 'E',
terminal: true,
scope: { val: '=', parentData:'=' },
link: function (scope, element, attrs) {
var template = '<span>{{val.text}}</span>';
template += '<button ng-click="deleteMe()" ng-show="val.text">delete</button>';
if (angular.isArray(scope.val.items)) {
template += '<ul class="indent"><li ng-repeat="item in val.items"><tree val="item" parent-data="val.items"></tree></li></ul>';
}
scope.deleteMe = function(index) {
if(scope.parentData) {
var itemIndex = scope.parentData.indexOf(scope.val);
scope.parentData.splice(itemIndex,1);
}
scope.val = {};
};
var newElement = angular.element(template);
$compile(newElement)(scope);
element.replaceWith(newElement);
}
}
});
I don't know for sure if this solution is found in one of the examples you linked or the same basic concept, but I had a need of a recursive directive, and I found a great, easy solution.
module.directive("recursive", function($compile) {
return {
restrict: "EACM",
priority: 100000,
compile: function(tElement, tAttr) {
var contents = tElement.contents().remove();
var compiledContents;
return function(scope, iElement, iAttr) {
if(!compiledContents) {
compiledContents = $compile(contents);
}
iElement.append(
compiledContents(scope,
function(clone) {
return clone; }));
};
}
};
});
module.directive("tree", function() {
return {
scope: {tree: '='},
template: '<p>{{ tree.text }}</p><ul><li ng-repeat="child in tree.children"><recursive><span tree="child"></span></recursive></li></ul>',
compile: function() {
return function() {
}
}
};
});​
You should create the recursive directive and then wrap it around the element that makes the recursive call.
As of Angular 1.5.x, no more tricks are required, the following has been made possible. No more need for dirty work arounds!
This discovery was a by product of my hunt for a better/cleaner solution for a recursive directive. You can find it here https://jsfiddle.net/cattails27/5j5au76c/. It supports as far is 1.3.x.
angular.element(document).ready(function() {
angular.module('mainApp', [])
.controller('mainCtrl', mainCtrl)
.directive('recurv', recurveDirective);
angular.bootstrap(document, ['mainApp']);
function recurveDirective() {
return {
template: '<ul><li ng-repeat="t in tree">{{t.sub}}<recurv tree="t.children"></recurv></li></ul>',
scope: {
tree: '='
},
}
}
});
function mainCtrl() {
this.tree = [{
title: '1',
sub: 'coffee',
children: [{
title: '2.1',
sub: 'mocha'
}, {
title: '2.2',
sub: 'latte',
children: [{
title: '2.2.1',
sub: 'iced latte'
}]
}, {
title: '2.3',
sub: 'expresso'
}, ]
}, {
title: '2',
sub: 'milk'
}, {
title: '3',
sub: 'tea',
children: [{
title: '3.1',
sub: 'green tea',
children: [{
title: '3.1.1',
sub: 'green coffee',
children: [{
title: '3.1.1.1',
sub: 'green milk',
children: [{
title: '3.1.1.1.1',
sub: 'black tea'
}]
}]
}]
}]
}];
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/angular.js/1.5.8/angular.min.js"></script>
<div>
<div ng-controller="mainCtrl as vm">
<recurv tree="vm.tree"></recurv>
</div>
</div>
After using several workarounds for a while, I've repeatedly come back to this issue.
I'm not satisfied by the service solution since it works for directives that can inject the service but does not work for anonymous template fragments.
Similarly, solutions which depend upon specific template structure by doing DOM manipulation in the directive are too specific and brittle.
I have what I believe is a generic solution that encapsulates the recursion as a directive of its own that interferes minimally with any other directives and can be used anonymously.
Below is a demonstration that you can also play around with at plnkr: http://plnkr.co/edit/MSiwnDFD81HAOXWvQWIM
var hCollapseDirective = function () {
return {
link: function (scope, elem, attrs, ctrl) {
scope.collapsed = false;
scope.$watch('collapse', function (collapsed) {
elem.toggleClass('collapse', !!collapsed);
});
},
scope: {},
templateUrl: 'collapse.html',
transclude: true
}
}
var hRecursiveDirective = function ($compile) {
return {
link: function (scope, elem, attrs, ctrl) {
ctrl.transclude(scope, function (content) {
elem.after(content);
});
},
controller: function ($element, $transclude) {
var parent = $element.parent().controller('hRecursive');
this.transclude = angular.isObject(parent)
? parent.transclude
: $transclude;
},
priority: 500, // ngInclude < hRecursive < ngIf < ngRepeat < ngSwitch
require: 'hRecursive',
terminal: true,
transclude: 'element',
$$tlb: true // Hack: allow multiple transclusion (ngRepeat and ngIf)
}
}
angular.module('h', [])
.directive('hCollapse', hCollapseDirective)
.directive('hRecursive', hRecursiveDirective)
/* Demo CSS */
* { box-sizing: border-box }
html { line-height: 1.4em }
.task h4, .task h5 { margin: 0 }
.task { background-color: white }
.task.collapse {
max-height: 1.4em;
overflow: hidden;
}
.task.collapse h4::after {
content: '...';
}
.task-list {
padding: 0;
list-style: none;
}
/* Collapse directive */
.h-collapse-expander {
background: inherit;
position: absolute;
left: .5px;
padding: 0 .2em;
}
.h-collapse-expander::before {
content: '•';
}
.h-collapse-item {
border-left: 1px dotted black;
padding-left: .5em;
}
.h-collapse-wrapper {
background: inherit;
padding-left: .5em;
position: relative;
}
<!DOCTYPE html>
<html>
<head>
<link href="collapse.css" rel="stylesheet" />
<link href="style.css" rel="stylesheet" />
<script data-require="angular.js#1.3.15" data-semver="1.3.15" src="https://code.angularjs.org/1.3.15/angular.js"></script>
<script src="//cdnjs.cloudflare.com/ajax/libs/jquery/2.1.1/jquery.min.js" data-semver="2.1.1" data-require="jquery#*"></script>
<script src="script.js"></script>
<script>
function AppController($scope) {
$scope.toggleCollapsed = function ($event) {
$event.preventDefault();
$event.stopPropagation();
this.collapsed = !this.collapsed;
}
$scope.task = {
name: 'All tasks',
assignees: ['Citizens'],
children: [
{
name: 'Gardening',
assignees: ['Gardeners', 'Horticulture Students'],
children: [
{
name: 'Pull weeds',
assignees: ['Weeding Sub-committee']
}
],
},
{
name: 'Cleaning',
assignees: ['Cleaners', 'Guests']
}
]
}
}
angular.module('app', ['h'])
.controller('AppController', AppController)
</script>
</head>
<body ng-app="app" ng-controller="AppController">
<h1>Task Application</h1>
<p>This is an AngularJS application that demonstrates a generalized
recursive templating directive. Use it to quickly produce recursive
structures in templates.</p>
<p>The recursive directive was developed in order to avoid the need for
recursive structures to be given their own templates and be explicitly
self-referential, as would be required with ngInclude. Owing to its high
priority, it should also be possible to use it for recursive directives
(directives that have templates which include the directive) that would
otherwise send the compiler into infinite recursion.</p>
<p>The directive can be used alongside ng-if
and ng-repeat to create recursive structures without the need for
additional container elements.</p>
<p>Since the directive does not request a scope (either isolated or not)
it should not impair reasoning about scope visibility, which continues to
behave as the template suggests.</p>
<p>Try playing around with the demonstration, below, where the input at
the top provides a way to modify a scope attribute. Observe how the value
is visible at all levels.</p>
<p>The collapse directive is included to further demonstrate that the
recursion can co-exist with other transclusions (not just ngIf, et al)
and that sibling directives are included on the recursive due to the
recursion using whole 'element' transclusion.</p>
<label for="volunteer">Citizen name:</label>
<input id="volunteer" ng-model="you" placeholder="your name">
<h2>Tasks</h2>
<ul class="task-list">
<li class="task" h-collapse h-recursive>
<h4>{{task.name}}</h4>
<h5>Volunteers</h5>
<ul>
<li ng-repeat="who in task.assignees">{{who}}</li>
<li>{{you}} (you)</li>
</ul>
<ul class="task-list">
<li h-recursive ng-repeat="task in task.children"></li>
</ul>
<li>
</ul>
<script type="text/ng-template" id="collapse.html">
<div class="h-collapse-wrapper">
<a class="h-collapse-expander" href="#" ng-click="collapse = !collapse"></a>
<div class="h-collapse-item" ng-transclude></div>
</div>
</script>
</body>
</html>
Now that Angular 2.0 is out in preview I think it's ok to add an Angular 2.0 alternative into the mix. At least it will benefit people later:
The key concept is to build a recursive template with a self reference:
<ul>
<li *for="#dir of directories">
<span><input type="checkbox" [checked]="dir.checked" (click)="dir.check()" /></span>
<span (click)="dir.toggle()">{{ dir.name }}</span>
<div *if="dir.expanded">
<ul *for="#file of dir.files">
{{file}}
</ul>
<tree-view [directories]="dir.directories"></tree-view>
</div>
</li>
</ul>
You then bind a tree object to the template and watch the recursion take care of the rest.
Here is a full example: http://www.syntaxsuccess.com/viewarticle/recursive-treeview-in-angular-2.0
There is a really really simple workaround for this that does not require directives at all.
Well, in that sense, maybe it is not even a solution of the original problem if you assume you need directives, but it IS a solution if you want a recursive GUI structure with parametrized sub-structures of the GUI. Which is probably what you want.
The solution is based on just using ng-controller, ng-init and ng-include. Just do it as follows, assume that your controller is called "MyController", your template is located in myTemplate.html and that you have an initialization function on your controller called init that takes argument A, B, and C, making it possible to parametrize your controller. Then the solution is as follows:
myTemplate.htlm:
<div>
<div>Hello</div>
<div ng-if="some-condition" ng-controller="Controller" ng-init="init(A, B, C)">
<div ng-include="'myTemplate.html'"></div>
</div>
</div>
I found by plain conincidence that this kind of structure can be made recursive as you like in plain vanilla angular. Just follow this design pattern and you can use recursive UI-structures without any advanced compilation tinkering etc.
Inside your controller:
$scope.init = function(A, B, C) {
// Do something with A, B, C
$scope.D = A + B; // D can be passed on to other controllers in myTemplate.html
}
The only downside I can see is the clunky syntax you have to put up with.
You can use angular-recursion-injector for that: https://github.com/knyga/angular-recursion-injector
Allows you to do unlimited depth nesting with conditioning. Does recompilation only if needed and compiles only right elements. No magic in code.
<div class="node">
<span>{{name}}</span>
<node--recursion recursion-if="subNode" ng-model="subNode"></node--recursion>
</div>
One of the things that allows it to work faster and simpler then the other solutions is "--recursion" suffix.
I ended up creating a set of basic directives for recursion.
IMO It is far more basic than the solution found here, and just as flexible if not more, so we are not bound to using UL/LI structures etc... But obviously those make sense to use, however the directives are unaware of this fact...
A Super simple example would be:
<ul dx-start-with="rootNode">
<li ng-repeat="node in $dxPrior.nodes">
{{ node.name }}
<ul dx-connect="node"/>
</li>
</ul>
The implementation of 'dx-start-with' an 'dx-connect' is found at: https://github.com/dotJEM/angular-tree
This means you don't have to create 8 directives if you need 8 different layouts.
To create a tree-view on top of that where you can add or delete nodes would then be rather simple. As in: http://codepen.io/anon/pen/BjXGbY?editors=1010
angular
.module('demo', ['dotjem.angular.tree'])
.controller('AppController', function($window) {
this.rootNode = {
name: 'root node',
children: [{
name: 'child'
}]
};
this.addNode = function(parent) {
var name = $window.prompt("Node name: ", "node name here");
parent.children = parent.children || [];
parent.children.push({
name: name
});
}
this.removeNode = function(parent, child) {
var index = parent.children.indexOf(child);
if (index > -1) {
parent.children.splice(index, 1);
}
}
});
<div ng-app="demo" ng-controller="AppController as app">
HELLO TREE
<ul dx-start-with="app.rootNode">
<li><button ng-click="app.addNode($dxPrior)">Add</button></li>
<li ng-repeat="node in $dxPrior.children">
{{ node.name }}
<button ng-click="app.removeNode($dxPrior, node)">Remove</button>
<ul dx-connect="node" />
</li>
</ul>
<script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.5.0/angular.min.js"></script>
<script src="https://rawgit.com/dotJEM/angular-tree-bower/master/dotjem-angular-tree.min.js"></script>
</div>
From this point on, the controller and template could be wrapped in it's own directive if one would wish for it.

Categories