<!DOCTYPE html>
<html>
<head>
<script type="text/javascript" src="https://code.angularjs.org/1.4.8/angular.js"></script>
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/3.10.0/lodash.js"></script>
<script type="text/javascript" src="https://code.jquery.com/jquery-1.9.1.js"></script>
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/angular.js/1.4.8/angular-route.min.js"></script>
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/angular.js/1.4.8/angular-sanitize.min.js"></script>
<link type="text/css" rel="stylesheet" href="style.css" />
<link type="text/css" rel="stylesheet" href="treeView.css" />
<link type="text/css" rel="stylesheet" href="//d170x0azrpb2m0.cloudfront.net/2.5.0/stylesheets/progress.min.css" />
<script src="treeView.js"></script>
<script src="script.js"></script>
<script src="example-data.js"></script>
</head>
<body>
<div ng-app="testApp" ng-controller="testCtrl">
<div ng-hide="hideUsage">
<button type="button" class="btn btn-blue btn-sm" ng-click="hideUsage = !hideUsage">Toggle Usage</button>
<h1>Usage</h1>
<div>
<p>
The directive is <strong>tree-view</strong> and has several configuration options available via attributes.
</p>
<p>The input data should be an array of objects, each with an id and parent id.</p>
</div>
<br>
<h2>Input Objects $$tree Property</h2>
<p>
The objects used as input for the directive can have a <strong>$$tree</strong> property, which is an object used to override the default behavior for an item in the tree. This <strong>$$tree</strong> property can contain two flags, <strong>canSelect</strong> and <strong>expanded</strong>, which the tree view with respect.
</p>
<br/>
<h2>Attributes</h2>
<div class="table-wrapper">
<table class="table">
<thead class="thead">
<tr>
<th scope="column">Attribute</th>
<th scope="column">Type</th>
<th scope="column">Description</th>
</tr>
</thead>
<tbody class="tbody">
<tr>
<td>
<div class="item-info">
<span class="item-name">ng-model</span>
<span class="item-sub-info">Required</span>
</div>
</td>
<td title="Should be an item from controller $scope">
<div class="item-info">
<span class="item-name">Reference</span>
<span class="item-sub-info">Two-Way binding</span>
</div>
</td>
<td>Output will be placed in this variable. For Multi-select, will be an array. For single select it will be a single item.</td>
</tr>
<tr>
<td>
<div class="item-info">
<span class="item-name">tree-data</span>
<span class="item-sub-info">Required</span>
</div>
</td>
<td title="Should be an item from controller $scope">
<div class="item-info">
<span class="item-name">Reference</span>
<span class="item-sub-info">One-Way binding</span>
</div>
</td>
<td>Should be set to the primary tree data source. Should be array of objects.</td>
</tr>
<tr>
<td>
<div class="item-info">
<span class="item-name">multiple</span>
<span class="item-sub-info">Optional</span>
</div>
</td>
<td title="Being present on the directive is enough to trigger">
<div class="item-info">
<span class="item-name">Flag</span>
<span class="item-sub-info">No binding</span>
</div>
</td>
<td>If present, allows for multiple items to be selected at once.</td>
</tr>
<tr>
<td title="Being present on the directive is enough to trigger">
<div class="item-info">
<span class="item-name">leaf-only</span>
<span class="item-sub-info">Optional</span>
</div>
</td>
<td title="Being present on the directive is enough to trigger">
<div class="item-info">
<span class="item-name" >Flag</span>
<span class="item-sub-info">No binding</span>
</div>
</td>
<td>If present, only items without children can be selected.</td>
</tr>
<tr>
<td title="Being present on the directive is enough to trigger">
<div class="item-info">
<span class="item-name">dropdown</span>
<span class="item-sub-info">Optional</span>
</div>
</td>
<td title="Being present on the directive is enough to trigger">
<div class="item-info">
<span class="item-name">Flag</span>
<span class="item-sub-info">No binding</span>
</div>
</td>
<td>If present, will use dropdown UI template.</td>
</tr>
<tr>
<td title="Being present on the directive is enough to trigger">
<div class="item-info">
<span class="item-name">select-children-with-parent</span>
<span class="item-sub-info">Optional</span>
</div>
</td>
<td title="Being present on the directive is enough to trigger">
<div class="item-info">
<span class="item-name">Flag</span>
<span class="item-sub-info">No binding</span>
</div>
</td>
<td><strong>Only works with multi-select mode.</strong> If present, will select all children when parent is selected.</td>
</tr>
<tr>
<td title="Being present on the directive is enough to trigger">
<div class="item-info">
<span class="item-name">deselect-children-with-parent</span>
<span class="item-sub-info">Optional</span>
</div>
</td>
<td title="Being present on the directive is enough to trigger">
<div class="item-info">
<span class="item-name">Flag</span>
<span class="item-sub-info">No binding</span>
</div>
</td>
<td><strong>Only works with multi-select mode.</strong> If present, will deselect all children when parent is deselected.</td>
</tr>
<tr>
<td>
<div class="item-info">
<span class="item-name">order-by</span>
<span class="item-sub-info">Optional</span>
</div>
</td>
<td title="Should be a string value">
<div class="item-info">
<span class="item-name">Value</span>
<span class="item-sub-info">No binding</span>
</div>
</td>
<td>Should be set a property name. If present, will use assigned property to order the tree data. The ordering is applied to each level of tree structure.</td>
</tr>
<tr>
<td>
<div class="item-info">
<span class="item-name">filter-by</span>
<span class="item-sub-info">Optional</span>
</div>
</td>
<td title="Should be a string value">
<div class="item-info">
<span class="item-name">Value</span>
<span class="item-sub-info">No binding</span>
</div>
</td>
<td><strong>Only works with dropdown mode.</strong> Should be set a property name, or multiple property names delimited by spaces and/or commas. If present, will use assigned property to order the tree data. The ordering is applied to each level of tree structure.</td>
</tr>
<tr>
<td>
<div class="item-info">
<span class="item-name">item-id-property</span>
<span class="item-sub-info">Optional, Default: "id"</span>
</div>
</td>
<td title="Should be a string value">
<div class="item-info">
<span class="item-name">Value</span>
<span class="item-sub-info">No binding</span>
</div>
</td>
<td>Should be set a property name. Used to determine the id for an object.</td>
</tr>
<tr>
<td>
<div class="item-info">
<span class="item-name">parent-id-property</span>
<span class="item-sub-info">Optional, Default: "parentId"</span>
</div>
</td>
<td title="Should be a string value">
<div class="item-info">
<span class="item-name">Value</span>
<span class="item-sub-info">No binding</span>
</div>
</td>
<td>Should be set a property name. Used to determine the parent id for an object.</td>
</tr>
<tr>
<td>
<div class="item-info">
<span class="item-name">item-output-property</span>
<span class="item-sub-info">Optional</span>
</div>
</td>
<td title="Should be a string value">
<div class="item-info">
<span class="item-name">Value</span>
<span class="item-sub-info">No binding</span>
</div>
</td>
<td>Should be set a property name. If present, ng-model will be assigned this property value rather than the full object.</td>
</tr>
<tr>
<td>
<div class="item-info">
<span class="item-name">display-property-tree</span>
<span class="item-sub-info">Optional, Default: "name"</span>
</div>
</td>
<td title="Should be a string value">
<div class="item-info">
<span class="item-name">Value</span>
<span class="item-sub-info">No binding</span>
</div>
</td>
<td>Should be set a property name. Used to display value for object within tree structure.</td>
</tr>
<tr>
<td>
<div class="item-info">
<span class="item-name">display-property-dropdown</span>
<span class="item-sub-info">Optional, Default: "name"</span>
</div>
</td>
<td title="Should be a string value">
<div class="item-info">
<span class="item-name">Value</span>
<span class="item-sub-info">No binding</span>
</div>
</td>
<td>Should be set a property name. Used to display value for object within selected section of dropdown.</td>
</tr>
<tr>
<td>
<div class="item-info">
<span class="item-name">dropdown-placeholder</span>
<span class="item-sub-info">Optional, Default: "Select something..."</span>
</div>
</td>
<td title="Should be a string value">
<div class="item-info">
<span class="item-name">Value</span>
<span class="item-sub-info">No binding</span>
</div>
</td>
<td>Should be a string value. Shown in dropdown area when nothing has been selected.</td>
</tr>
<tr>
<td>
<div class="item-info">
<span class="item-name">dropdown-search-placeholder</span>
<span class="item-sub-info">Optional, Default: "Search..."</span>
</div>
</td>
<td title="Should be a string value">
<div class="item-info">
<span class="item-name">Value</span>
<span class="item-sub-info">No binding</span>
</div>
</td>
<td>Should be a string value. Shown in filter textbox within the dropdown. <strong>Only used when filter-by is present.</strong></td>
</tr>
</tbody>
</table>
</div>
<hr/>
</div>
<button type="button" class="btn btn-blue btn-sm" ng-click="hideUsage = !hideUsage">Toggle Usage</button>
<div ng-show="currentInputIdx < 0">
<h4>Select Data</h4>
<button type="button" class="btn btn-green" ng-click="currentInputIdx = 0">Animal Data</button>
<button type="button" class="btn btn-green" ng-click="currentInputIdx = 1">Political Data</button>
</div>
<div ng-show="currentInputIdx >= 0">
<button class="btn btn-yellow btn-xs" ng-click="currentInputIdx = -1">Switch Data</button>
<pre class="scrollable" ng-show="showData">{{activeData | jsonPrint}}</pre>
<button ng-hide="showData" class="btn btn-green btn-xs" ng-click="showData = true">Show Data</button>
<button ng-show="showData" class="btn btn-red btn-xs" ng-click="showData = false">Hide Data</button>
</div>
<div ng-show="currentInputIdx >= 0">
<hr>
<!-- Actual Example Usage Starts Here -->
<h1>Core Trees</h1>
<br>
<h4>Multiple Select</h4>
<tree-view ng-model="selected[0]" tree-data="activeData" multiple order-by="name"></tree-view>
Output: {{selected[0]}}
<br>
<h4>Single Select</h4>
<tree-view ng-model="selected[1]" tree-data="activeData" order-by="name"></tree-view>
Output: {{selected[1]}}
<hr>
<h4>Multiple Select (Leaf Only)</h4>
<tree-view ng-model="selected[2]" tree-data="activeData" multiple order-by="name" leaf-only></tree-view>
Output: {{selected[2]}}
<br>
<h4>Single Select (Leaf Only)</h4>
<tree-view ng-model="selected[3]" tree-data="activeData" order-by="name" leaf-only></tree-view>
Output: {{selected[3]}}
<hr>
<h1>Dropdowns</h1>
<br>
<h4>Multiple Select</h4>
<tree-view ng-model="selected[4]" tree-data="activeData" multiple dropdown></tree-view>
Output: {{selected[4]}}
<br>
<h4>Single Select</h4>
<tree-view ng-model="selected[5]" tree-data="activeData" dropdown></tree-view>
Output: {{selected[5]}}
<hr>
<h1>Dropdowns with Filtering</h1>
<br>
<h4>Multiple Select</h4>
<tree-view ng-model="selected[6]" tree-data="activeData" multiple dropdown filter-by="name"></tree-view>
Output: {{selected[6]}}
<br>
<h4>Single Select</h4>
<tree-view ng-model="selected[7]" tree-data="activeData" dropdown filter-by="name"></tree-view>
Output: {{selected[7]}}
<hr>
<h1>Dropdowns with Child-Parent Selection Behavior</h1>
<br>
<h4>Multiple Select: Select Children with Parent</h4>
<tree-view ng-model="selected[8]" tree-data="activeData" multiple dropdown select-children-with-parent></tree-view>
Output: {{selected[8]}}
<br>
<h4>Multiple Select: Deselect Children with Parent</h4>
<tree-view ng-model="selected[9]" tree-data="activeData" multiple dropdown deselect-children-with-parent></tree-view>
Output: {{selected[9]}}
<br>
<h4>Multiple Select: Select & Deselect Children with Parent</h4>
<tree-view ng-model="selected[10]" tree-data="activeData" multiple dropdown select-children-with-parent deselect-children-with-parent></tree-view>
Output: {{selected[10]}}
<hr>
<h1>Dropdowns with Shifting Output</h1>
<nav class="app-nav">
<ul class="nav-tabs">
<li ng-class="{'active': currentIdx == i}" ng-repeat="i in shifts">
<a href="#" ng-click="setShift(i)">Output {{i+1}}</a>
</ul>
</nav>
<br>
<tree-view ng-model="output.shifting" tree-data="activeData" multiple dropdown></tree-view>
Current Output: {{output.shifting}}
<br>
All Output: {{shifting}}
<br>
<tree-view ng-model="output.shiftingSingle" tree-data="activeData" dropdown></tree-view>
Current Output: {{output.shiftingSingle}}
<br>
All Output: {{shiftingSingle}}
<hr>
<h4>With Alternate Output</h4>
<tree-view ng-model="output.shiftingAlt" tree-data="activeData" multiple dropdown item-output-property="id"></tree-view>
Current Output: {{output.shiftingAlt}}
<br>
All Output: {{shiftingAlt}}
<br>
<tree-view ng-model="output.shiftingSingleAlt" tree-data="activeData" dropdown item-output-property="id"></tree-view>
Current Output: {{output.shiftingSingleAlt}}
<button class="btn btn-blue btn-xs" ng-click="testValue('output.shiftingSingleAlt')">Check Value</button>
<br>
All Output: {{shiftingSingleAlt}}
</div>
</div>
</body>
</html>
(function(){
angular.module('testApp',['puiTreeView', 'exampleData'])
.controller('testCtrl',['$scope','flatData',
function($scope, flatData){
$scope.currentInputIdx = -1;
$scope.selected = [];
$scope.data = [];
Object.defineProperty($scope,'activeData',{
get: function(){
return $scope.data[$scope.currentInputIdx];
}
});
$scope.data[0] = flatData.animals;
$scope.data[1] = flatData.political.all;
$scope.shifting = [];
$scope.shiftingSingle = [];
$scope.shiftingAlt = [];
$scope.shiftingSingleAlt = [];
$scope.currentIdx = 0;
$scope.shifts = [
0,1,2,3,4
];
$scope.setShift = function(i){
$scope.currentIdx = i;
};
$scope.output = {};
Object.defineProperty($scope.output,'shifting',{
get: function(){
return $scope.shifting[$scope.currentIdx];
},
set: function(val){
$scope.shifting[$scope.currentIdx] = val;
}
});
Object.defineProperty($scope.output,'shiftingSingle',{
get: function(){
return $scope.shiftingSingle[$scope.currentIdx];
},
set: function(val){
$scope.shiftingSingle[$scope.currentIdx] = val;
}
});
Object.defineProperty($scope.output,'shiftingAlt',{
get: function(){
return $scope.shiftingAlt[$scope.currentIdx];
},
set: function(val){
$scope.shiftingAlt[$scope.currentIdx] = val;
}
});
Object.defineProperty($scope.output,'shiftingSingleAlt',{
get: function(){
return $scope.shiftingSingleAlt[$scope.currentIdx];
},
set: function(val){
$scope.shiftingSingleAlt[$scope.currentIdx] = val;
}
});
$scope.testValue = function(prop){
alert(_.get($scope,prop));
};
}]).filter('jsonPrint', [function(){
return function(data){
return angular.toJson(data,2);
};
}]);
})();
.scrollable {
max-height: 500px;
overflow: scroll;
}
Tree View with multiple UI presentations.
Implemented in Angular and lodash. Some jQuery is used in a couple spots.
(function(){
angular.module('exampleData',[])
.factory('politicalData',[function(){
var federal = {
id: 'UNIVERSE_Federal',
name: 'Federal',
children: [
{
id: 'BRANCH_Executive_Federal',
name: 'Executive',
children: [
{
id:'CATEGORY_Whitehouse',
name: 'White House',
children: [
{
id: 'OFFICE_POTUS',
name: 'President'
},
{
id: 'OFFICE_VPOTUS',
name: 'Vice President'
},
{
id: 'OFFICE_FLOTUS',
name: 'First Lady'
}
]
}
,
{
id: 'AGENCY_ALL',
name: 'Federal Agencies',
children: [
{
id:'AGENCY_DOD',
name: 'Department of Defense'
},
{
id: 'AGENCY_HLS',
name: 'Homeland Security'
}
]
}
]
},
{
id: 'BRANCH_Legislative_Federal',
name: 'Legislative',
children:[
{
id:'OFFICE_US_House',
name: 'US House',
children:[
{
id:'LEADERSHIP_House_Speaker',
name:'Speaker of the House'
}
]
},
{
id: 'OFFICE_US_Senate',
name: 'US Senate',
children: [
{
id: 'LEADERSHIP_Senate_Leader',
name: 'Senate Majority Leader'
}
]
}
]
}
]
};
var state = {
id: 'UNIVERSE_State',
name: 'State',
children: [
{
id:'BRANCH_Executive',
name: 'Executive',
children: [
{
id:'OFFICE_Governor',
name: 'Governor'
}
]
},
{
id: 'BRANCH_Legislative',
name:'Legislative',
children: [
{
id: 'OFFICE_State_Upper',
name: 'Upper'
},
{
id: 'OFFICE_State_Lower',
name: 'Lower'
}
]
}
]
};
var political = [
federal,
state
];
return {
all: political,
state: state.children,
federal: federal.children
};
}])
.factory('flatData', [
'politicalData',
function(politicalData){
var flatData = [
{
id:1,
name: 'Mammals'
},
{
id:2,
name: 'Reptiles'
},
{
id: 3,
name: 'Amphibians'
},
{
id:4,
name: 'Frog',
parentId: 3
},
{
id:5,
name: 'Poison Dart Frog',
parentId:4
},
{
id:6,
name:'Toad',
parentId:3
},
{
id:7,
name: 'Lion',
parentId: 1
},
{
id:8,
name: 'Tiger',
parentId: 1
},
{
id:9,
name: 'Bear',
parentId:1
},
{
id:10,
name: 'Black Bear',
parentId:9
},
{
id: 11,
name: 'Grizzly Bear',
parentId: 9
},
{
id: 12,
name: 'Snake',
parentId: 2
},
{
id: 13,
name: 'Lizard',
parentId: 2
}
];
function flattenData(data, idProp,parentIdProp, childrenProperty, result){
result = result || [];
if(_.isArray(data)){
_.forEach(data, function(d){
flattenData(d, idProp, parentIdProp, childrenProperty, result);
});
return result;
}
var id = _.get(data,idProp);
var children = _.get(data, childrenProperty);
result.push(data);
_.forEach(children, function(child){
_.set(child,parentIdProp,id);
flattenData(child,idProp,parentIdProp, childrenProperty,result);
});
_.set(data,childrenProperty,undefined);
return result;
}
function rebuildWithNewTop(newTop,data,idProp, parentIdProp){
data = _.cloneDeep(data);
var topId = _.get(newTop,idProp);
_.forEach(data, function(d){
var parent = _.get(d,parentIdProp);
if(!parent){
_.set(d,parentIdProp,topId);
}
});
return _.flatten([newTop,data]);
}
var flatState = flattenData(_.cloneDeep(politicalData.state),'id','parentId','children');
var flatFederal = flattenData(_.cloneDeep(politicalData.federal),'id','parentId','children');
var stateTop = {id:'UNIVERSE_State', name: 'State'};
var federalTop = {id: 'UNIVERSE_Federal', name:'Federal'};
var flatAll = _.flatten([
rebuildWithNewTop(stateTop,flatState,'id','parentId'),
rebuildWithNewTop(federalTop,flatFederal,'id','parentId')
]);
return {
animals: flatData,
political:
{
all: flatAll,
state: flatState,
federal: flatFederal
}
};
}]);
})();
(function () {
angular.module('puiTreeView', ['ngSanitize'])
.constant('treeViewConfig', {
templates: {
dropdown: {
multiple: 'multi-select.tpl.html',
single: 'single-select.tpl.html'
},
simple: 'core.tpl.html'
}
})
.directive('treeView', ['treeViewConfig', 'attributeHelper', '$timeout',
function (treeViewConfig, attributeHelper, $timeout) {
return {
restrict: 'E',
controllerAs: '$tree',
require: ['treeView', 'ngModel'],
bindToController: {
treeData: '='
},
scope: true,
templateUrl: function (elem, attrs) {
var multiple = attributeHelper.truthy(attrs.multiple);
var dropdown = attributeHelper.truthy(attrs.dropdown);
var tpl = treeViewConfig.templates.simple;
if (dropdown) {
tpl = multiple ?
treeViewConfig.templates.dropdown.multiple :
treeViewConfig.templates.dropdown.single;
}
return attrs.templateUrl || tpl;
},
controller: [
'$scope', '$attrs', '$parse', 'attributeHelper', '$sce',
function ($scope, $attrs, $parse, attributeHelper, $sce) {
var ctrl = this;
function parse(attr) {
return function (val) {
var parsed = $parse(attr);
if (arguments.length) {
//set
parsed.assign($scope, val);
} else {
//get
return parsed($scope);
}
};
}
function isInitiallySelected(item) {
var modelData = ctrl.selected();
if (!modelData || !modelData.length) {
return false;
}
return _.any(modelData, function(value){
if(ctrl.config.outputProperty){
return _.isEqual(value, _.get(item,ctrl.config.outputProperty));
}
return _.isEqual(value,item);
});
}
function initWrapper(item, depth) {
var data = {
children: [],
selected: isInitiallySelected(item),
canSelect: true,
canExpand: false,
expanded: false,
filterExpanded: false,
filterMatch: false,
item: item,
isChild: true,
depth: depth,
show: true,
init: _.extend({canSelect:true, expanded: false}, _.get(item,'$$tree') || {})
};
ctrl.allData.push(data);
return data;
}
function updateWrapper(data) {
if (!data.init.canSelect || (ctrl.config.leafOnly && data.children.length) ) {
data.canSelect = false;
}
if (data.children.length > 0) {
data.canExpand = true; //for css class
if(data.init.expanded){
data.expanded = true;
}
}
}
function initItemFlat(item, allItems, depth) {
depth = depth || 0;
var data = initWrapper(item, depth);
var children = getChildren(item, allItems);
_.forEach(children, function (child) {
var childData = initItemFlat(child, allItems, depth + 1);
data.children.push(childData);
});
updateWrapper(data);
return data;
}
function syncToModel(){
ctrl.status.modifyingSelected = true;
if(ctrl.config.multiple){
var selectedOutput = ctrl.config.outputProperty ?
_.pluck($scope.dropdown.selected,ctrl.config.outputProperty) :
$scope.dropdown.selected;
ctrl.selected(selectedOutput);
} else {
var output = ctrl.config.outputProperty ?
_.get($scope.item,ctrl.config.outputProperty) :
$scope.item;
ctrl.selected(output);
}
$timeout(function () {
ctrl.status.modifyingSelected = false;
});
}
function getDataForItem(item) {
return _.find(ctrl.allData, function (data) {
return angular.equals(data.item, item);
});
}
function getDataForOutputValue(val){
if(!ctrl.config.outputProperty){
return getDataForItem(val);
}
return _.find(ctrl.allData, function(data){
var item = data.item;
var oVal = _.get(item, ctrl.config.outputProperty);
return angular.equals(oVal,val);
});
}
function syncFromModel(){
ctrl.status.modifyingSelected = true;
var selected = ctrl.selected();
deselectAll();
if(ctrl.config.multiple){
if(!_.isArray(selected)){
selected = [selected];
}
$scope.dropdown.selected = _(selected).map(getDataForOutputValue).filter().forEach(function(data){
_.set(data,'selected',true);
}).pluck('item').value();
} else {
var selectedData = getDataForOutputValue(selected);
_.set(selectedData,'selected',true);
$scope.item = _.get(selectedData,'item');
}
$timeout(function () {
ctrl.status.modifyingSelected = false;
});
}
function addToData(data) {
var item = data.item;
if(ctrl.config.multiple){
$scope.dropdown.selected = $scope.dropdown.selected || [];
$scope.dropdown.selected.push(item);
} else {
$scope.item = item;
}
syncToModel();
}
function removeFromData(data) {
if (ctrl.config.multiple) {
//is array
var mData = $scope.dropdown.selected;
var idx = _.indexOf(mData, data.item);
_.pullAt(mData, idx);
} else {
$scope.item = null;
}
syncToModel();
}
function deselectAll(){
//deselect everything
_.forEach(ctrl.allData, function (data) {
data.selected = false;
});
}
function doSelect(data) {
if (!ctrl.config.multiple) {
//deselect everything
deselectAll();
}
data.selected = true;
addToData(data);
$scope.dropdown.filter = '';
if(ctrl.config.multiple && ctrl.config.behavior.selectChildrenWithParent){
_.forEach(data.children,function(child){
doSelect(child);
});
}
}
function doDeselect(data) {
//deselect
data.selected = false;
removeFromData(data);
if(ctrl.config.multiple && ctrl.config.behavior.deselectChildrenWithParent){
_.forEach(data.children,function(child){
doDeselect(child);
});
}
}
function getChildren(item, allItems) {
var parentId = _.get(item, ctrl.config.idProperty);
return _.filter(allItems, function (other) {
return _.isEqual(_.get(other, ctrl.config.parentIdProperty), parentId);
});
}
function toggleSelect(data) {
if (!data.selected) {
if(!data.canSelect){
return;
}
var selected = ctrl.selected();
var children = data.children;
if (ctrl.config.leafOnly && children && children.length > 0) {
return;
}
if (!ctrl.config.multiple && selected) {
//deselect current
var other = _.findWhere(ctrl.allData, { selected: true });
if (other) {
doDeselect(other);
}
}
doSelect(data);
$scope.dropdown.expanded = false;
} else {
doDeselect(data);
}
}
function toggleExpand(data) {
if(data.filterExpanded){
data.filterExpanded = false;
data.expanded = false;
} else {
data.expanded = !data.expanded;
}
}
var matchesFilters = _.memoize(function (data, filterValue) {
var ret = false;
_.forEach(ctrl.filters, function (prop) {
var val = _.get(data.item, prop);
val = val ? val.toString() : val;
if (val && val.toLowerCase().indexOf(filterValue) >= 0) {
ret = true;
return false;//short-circuit
}
});
return ret;
}, function (data, filterValue) {
return JSON.stringify(data.item) + "::" + filterValue;
});
// Dropdown stuff
function doNotClose(event) {
if ($scope.dropdown.expanded) {
event.stopPropagation();
}
}
function closeDropdown(){
$scope.dropdown.expanded = false;
$scope.dropdown.filter = '';
}
function blur() {
if ($scope.dropdown.expanded) {
closeDropdown();
}
}
function expandDropdown() {
if (!$scope.dropdown.expanded) {
$scope.dropdown.expanded = true;
} else {
closeDropdown();
}
}
function deselectItem(item, event) {
if (!arguments.length) {
item = $scope.item;
}
var data = getDataForItem(item);
if (data && data.selected) {
toggleSelect(data);
}
if (event)
event.stopPropagation();
}
// ---
function orderTree(data) {
if (!ctrl.config.orderBy) {
return;
}
_.forEach(data.children, function (child) {
orderTree(child);
});
data.children = _.sortBy(data.children, function (child) {
return _.get(child.item, ctrl.config.orderBy);
});
}
function initSelections(skipSyncToModel) {
syncFromModel();
if (!skipSyncToModel) {
syncToModel();
}
}
function init() {
ctrl.config = {
data: ctrl.treeData,
multiple: attributeHelper.truthy($attrs.multiple),
dropdown: attributeHelper.truthy($attrs.dropdown),
idProperty: $attrs.itemIdProperty || 'id',
parentIdProperty: $attrs.itemParentIdProperty || 'parentId',
outputProperty: $attrs.itemOutputProperty,
orderBy: $attrs.orderBy,
leafOnly: attributeHelper.truthy($attrs.leafOnly),
displayProperties: {
tree: $attrs.displayPropertyTree || 'name',
dropdown: $attrs.displayPropertyDropdown || 'name'
},
dropdown: {
placeholder: $attrs.dropdownPlaceholder || 'Select something...',
searchPlaceholder: $attrs.dropdownSearchPlaceholder || 'Search...'
},
behavior:{
selectChildrenWithParent: attributeHelper.truthy($attrs.selectChildrenWithParent),
deselectChildrenWithParent: attributeHelper.truthy($attrs.deselectChildrenWithParent)
}
};
ctrl.filters = _.filter(($attrs.filterBy || '').split(/\s+|,/));
ctrl.status = {
modifyingSelected: false
};
ctrl.allData = [];
ctrl.selected = parse($attrs.ngModel);
var flat = ctrl.config.data;//();
//get all top level items
var root = {
children: []
};
var topItems = _.filter(flat, function (item) {
return !_.get(item, ctrl.config.parentIdProperty);
});
_.forEach(topItems, function (item) {
var topData = initItemFlat(item, flat);
topData.isChild = false;
root.children.push(topData);
});
orderTree(root);
$scope.hasFilters = !!ctrl.filters.length;
$scope.data = root;
$scope.displayHtml = $sce.trustAsHtml('{{data.item.' + ctrl.config.displayProperties.tree + '}}');
$scope.selectedHtml = $sce.trustAsHtml('{{item.' + ctrl.config.displayProperties.dropdown + '}}');
$scope.dropdown = {
expanded: false,
filter: '',
placeholder: ctrl.config.dropdown.placeholder,
searchPlaceholder: ctrl.config.dropdown.searchPlaceholder,
doNotClose: doNotClose,
blur: blur,
expand: expandDropdown,
deselect: deselectItem
};
//init selections
initSelections(true);
}
$scope.toggleExpand = toggleExpand;
$scope.toggleSelect = toggleSelect;
init();
$scope.$watchCollection($attrs.treeData, function (newValue, oldValue) {
if (!_.isEqual(oldValue,newValue)) {
init();
}
});
if (ctrl.config.multiple) {
$scope.$watchCollection($attrs.ngModel, function (newValue, oldValue) {
if (!_.isEqual(oldValue,newValue) && !ctrl.status.modifyingSelected) {
initSelections();
}
});
} else {
$scope.$watch($attrs.ngModel, function (newValue, oldValue) {
if (!_.isEqual(oldValue,newValue) && !ctrl.status.modifyingSelected) {
initSelections();
}
});
}
function handleFilter(data, filterValue, parentIsMatch){
var isMatch = matchesFilters(data, filterValue);
var childIsMatch;
if(data.children && data.children.length){
_.forEach(data.children, function(child){
handleFilter(child, filterValue, isMatch || parentIsMatch);
if(child.filterMatch || child.filterExpanded){
childIsMatch = true;
}
});
}
data.filterMatch = isMatch;
data.filterExpanded = childIsMatch;
data.show = isMatch || parentIsMatch || childIsMatch;
}
function resetAll(){
_.forEach(ctrl.allData, function(data){
data.filterMatch = false;
data.filterExpanded = false;
data.show = true;
});
}
if(ctrl.config.dropdown && ctrl.filters.length){
//filters and dropdown are enabled
$scope.$watch(function(){
return $scope.dropdown.filter;
}, function(newValue, oldValue){
if(!_.isEqual(oldValue,newValue)){
if(!newValue){
//show all
resetAll();
} else {
//figure out what should be visible
newValue = newValue.toLowerCase();
_.forEach($scope.data.children, function(topData){
handleFilter(topData,newValue);
});
}
}
});
}
}]
};
}])
.factory('attributeHelper', [
function () {
return {
truthy: function (val) {
return angular.isDefined(val) && (val === '' || val.toLowerCase() === 'true');
}
}
}])
.directive('compileTemplate', ["$compile", "$parse", function ($compile, $parse) {
//src: http://stackoverflow.com/questions/25406461/angularjs-ng-bind-html-2way-data-binding
return {
restrict: 'A',
link: function ($scope, element, attr) {
var parse = $parse(attr.ngBindHtml);
function value() { return (parse($scope) || '').toString(); }
$scope.$watch(value, function () {
$compile(element, null, -9999)($scope);
});
}
}
}]).directive("outsideClick", ['$document', '$parse', '$timeout', function ($document, $parse, $timeout) {
return {
link: function ($scope, $element, $attributes) {
var scopeExpression = $attributes.outsideClick,
enabled = $attributes.outsideClickEnabled,
onDocumentClick = function (event) {
//need jquery for this part
var isChild = $($element).has(event.target).length > 0;
var isSelf = $element[0] == event.target;
var isInside = isChild || isSelf;
if (!isChild && !isSelf && !isInside) {
$scope.$apply(scopeExpression);
}
};
$document.on("click", onDocumentClick);
$element.on('$destroy', function () {
$document.off("click", onDocumentClick);
});
}
};
}]).directive('focusMe', ['$timeout','$parse',function ($timeout, $parse) {
//based on watching a flag, set focus to an element
//src: http://stackoverflow.com/questions/14833326/how-to-set-focus-on-input-field
return {
link: function (scope, element, attrs) {
var model = $parse(attrs.focusMe);
scope.$watch(model, function (value) {
if (value === true) {
$timeout(function () {
element[0].focus();
});
}
});
}
};
}]);
})();
<div class="treeview-container" outside-click="dropdown.blur()">
<div class="selected-items-wrapper" ng-click="dropdown.expand()">
<div class="no-selection-placeholder">
<span ng-hide="dropdown.selected.length">{{dropdown.placeholder}}</span>
<span class="glyphicons glyphicons-plus open"></span>
</div>
<div class="selected-item-container" ng-repeat="item in dropdown.selected">
<span ng-click="$event.stopPropagation()" class="selected-item">
<span ng-bind-html="selectedHtml" compile-template></span> <a class="selected-item-deselect" ng-click="dropdown.deselect(item,$event)">×</a>
</span>
</div>
</div>
<div class="dropdown-list" ng-show="dropdown.expanded">
<div class="typeahead-container" ng-if="hasFilters">
<input type="text" ng-model="dropdown.filter" placeholder="{{dropdown.searchPlaceholder}}" ng-click="dropdown.doNotClose($event)" class="input-element" focus-me="dropdown.expanded" ng-model-options="{debounce:1}"/>
</div>
<div ng-include="'core.tpl.html'"></div>
</div>
</div>
<div class="treeview-container" outside-click="dropdown.blur()">
<div class="selected-items-wrapper" ng-click="dropdown.expand()">
<div ng-show="item" class="selected-single-item-wrapper">
<span class="selected-single-item">
<span ng-bind-html="selectedHtml" compile-template></span>
<a ng-click="dropdown.deselect()" style="float:right"><span class="glyphicons glyphicons-remove-2 single-item-remove"></span></a>
</span>
</div>
<div ng-hide="item" class="no-selection-placeholder">
<span>{{dropdown.placeholder}}</span>
</div>
</div>
<div class="dropdown-list" ng-show="dropdown.expanded">
<div class="typeahead-container" ng-if="hasFilters">
<input type="text" ng-model="dropdown.filter" placeholder="{{dropdown.searchPlaceholder}}" ng-click="dropdown.doNotClose($event)" class="input-element" focus-me="dropdown.expanded" ng-model-options="{debounce:1}" />
</div>
<div ng-include="'core.tpl.html'"></div>
</div>
</div>
<script type="text/ng-template" id="treeNode">
<ul>
<li ng-repeat="data in data.children"
data-depth="{{data.depth}}"
ng-show="data.show"
class="tree-item-row"
ng-class="{'tree-item-row-expanded':data.expanded, 'tree-item-row-collapsed': data.canExpand && !data.expanded, 'tree-item-row-selected':data.selected, 'tree-item-row-can-select':data.canSelect, 'tree-item-row-cannot-select':!data.canSelect, 'tree-item-row-cannot-expand': !data.children || !data.children.length}">
<a ng-click="toggleExpand(data)"
ng-show="data.children && data.children.length"
class="tree-item-expand"
ng-class="{'tree-expand-arrow-expanded': data.expanded, 'tree-expand-arrow-collapsed':!data.expanded}">
<span class="glyphicons"
ng-class="{'glyphicons-chevron-down': data.expanded || data.filterExpanded, 'glyphicons-chevron-right': !(data.expanded || data.filterExpanded)}"></span>
</a>
<a
ng-click="toggleSelect(data)"
class="tree-item"
ng-class="{'selected':data.selected,'can-select':data.canSelect, 'cannot-select':!data.canSelect}"
compile-template
ng-bind-html="displayHtml"></a>
<div class="child-tree"
ng-show="data.expanded || data.filterExpanded"
ng-include="'treeNode'"></div>
</li>
</ul>
</script>
<div ng-include="'treeNode'" class="root-tree"></div>
.dropdown-list {
-webkit-box-shadow: 1px 1px 3px #dde4ed;
-ms-box-shadow: 1px 1px 3px #dde4ed;
box-shadow: 1px 1px 3px #dde4ed;
z-index: 9999;
-ms-border-bottom-right-radius: 2px;
border-bottom-right-radius: 2px;
-ms-border-bottom-left-radius: 2px;
border-bottom-left-radius: 2px;
background-color: white;
border: 1px solid #B2C1DB;
margin-top: -1px;
position: absolute;
width: 100%;
}
.tree-item-row .selected {
color: #98a4b8;
}
.dropdown-list .root-tree {
max-height: 250px;
overflow: auto;
}
.dropdown-list .typeahead {
border: 1px solid #B2C1DB !important;
-ms-border-radius: 2px;
border-radius: 2px;
display: block;
padding: 10px 14px;
width: 100%;
}
.dropdown-list .typeahead:focus {
border: 1px solid #B2C1DB !important;
}
.dropdown-list .selected {
color: #98a4b8;
}
.dropdown-list .selected:hover {
color: #98a4b8;
}
.typeahead-container {
position: relative;
padding: 14px 10px 0 14px;
margin-bottom: 5px;
}
.selected {
color: #394357;
}
.tree-item-row-can-select > .tree-item:hover {
color: #0d98e6;
}
.tree-item-row-cannot-select > .tree-item:hover {
cursor: default;
}
.selected-items-wrapper {
background-color: white;
}
.tree-item-expand:hover {
color: #394357;
}
.selected-item-deselect {
color: gray;
margin-left: 6px;
}
.selected-item-deselect:hover {
color: #394357;
text-decoration: none;
}
.selected-single-item {
display: block;
}
.no-selection-placeholder {
display: block;
color: #98a4b8;
padding-top: 2px;
}
.tree-item-row-cannot-expand .tree-item {
margin-left: 15px;
}
.selected-item-container + .typeahead {
min-height: 34px;
padding: 6px 8px;
}
.single-item-remove {
height: 18px;
cursor: pointer;
}
.treeview-container {
position: relative;
}
.tree-item-expand {
display: inline-block;
line-height: 22px;
vertical-align: middle;
cursor: pointer;
}
.tree-item-expand span {
font-size: 12px;
margin-right: 0;
}
.typeahead {
border: 0px !important;
font-size: 14px;
-moz-min-width: 50px;
-ms-min-width: 50px;
-o-min-width: 50px;
-webkit-min-width: 50px;
min-width: 50px;
outline: none;
padding: 3px;
}
.typeahead:focus {
border: 0px !important;
outline: none;
}
.root-tree ul {
color: #394357;
list-style-type: none;
padding: 0 14px;
}
.root-tree ul ul {
padding: 0 24px;
}
.selected-item {
-ms-border-radius: 2px;
border-radius: 2px;
border: 1px solid #B2C1DB;
float: left;
line-height: 16px;
padding: 6px 8px;
margin: 2px;
}
.selected-item-deselect {
font-size: 16px;
cursor: pointer;
}
.no-selection-placeholder .open {
float: right;
color: white;
font-size: 1px;
}
.selected-items-wrapper {
cursor: pointer;
-ms-border-radius: 2px;
border-radius: 2px;
border: 1px solid #B2C1DB;
min-height: 38px;
overflow: auto;
font-size: 14px;
padding: 8px 10px;
}
a.tree-item {
color: black;
text-decoration: none;
}
a.tree-item.cannot-select {
cursor: default;
}
a.tree-item.cannot-select:hover {
color:black;
}
a.tree-item.can-select {
cursor: pointer;
}
.tree-item-row {
line-height: 22px;
min-height: 25px;
}
- Ability to collapse trees when filtering
- Would need to rearrange things. ng-show would be off a property on wrapper data
- property would be populated via a $watch on $scope.dropdown.filter
- (configurable) When filtering, show all children if parent matches filter
- only expand parent if a child matches