angular.module('angular-table', []) .directive('angularTable', ['SortState', 'TemplateStaticState', function(SortState, TemplateStaticState) { return { // only support elements for now to simplify the manual transclusion and replace logic. restrict: 'E', // manually transclude and replace the template to work around not being able to have a template with td or tr as a root element // see bug: https://github.com/angular/angular.js/issues/1459 compile: function (tElement, tAttrs) { SortState.sortExpression = tAttrs.defaultSortColumn; TemplateStaticState.instrumentationEnabled = tAttrs.instrumentationEnabled; TemplateStaticState.modelName = tAttrs.model; // find whatever classes were passed into the angular-table, and merge them with the built in classes for the container div tElement.addClass('angularTableContainer'); var rowTemplate = tElement[0].outerHTML.replace('', ''); tElement.replaceWith(rowTemplate); // return linking function return function(scope, elem, attrs) { scope.parent = scope.$parent; }; }, scope: { model: '=', filterQueryModel: '=' } }; }]) .directive('headerRow', ['ManualCompiler', 'ScrollingContainerHeightState', 'JqLiteExtension', 'SortState', 'ResizeHeightEvent', 'ResizeWidthEvent', 'Instrumentation', function(ManualCompiler, ScrollingContainerHeightState, JqLiteExtension, SortState, ResizeHeightEvent, ResizeWidthEvent, Instrumentation) { return { // only support elements for now to simplify the manual transclusion and replace logic. restrict: 'E', controller: ['$scope', '$parse', function($scope, $parse) { $scope.SortState = SortState; $scope.setSortExpression = function(columnName) { SortState.sortExpression = columnName; // track sort directions by sorted column for a better ux SortState.sortDirectionToColumnMap[SortState.sortExpression] = !SortState.sortDirectionToColumnMap[SortState.sortExpression]; }; }], // manually transclude and replace the template to work around not being able to have a template with td or tr as a root element // see bug: https://github.com/angular/angular.js/issues/1459 compile: function (tElement, tAttrs) { ManualCompiler.compileRow(tElement, tAttrs, true); // return a linking function return function(scope, iElement) { scope.ResizeHeightEvent = ResizeHeightEvent; scope.ResizeWidthEvent = ResizeWidthEvent; // update the header width when the scrolling container's width changes due to a scrollbar appearing // watches get called n times until the model settles. it's typically one or two, but processing in the functions // must be idempotent and as such shouldn't rely on it being any specific number. scope.$watch('ResizeWidthEvent', function() { // pull the computed width of the scrolling container out of the dom var scrollingContainerComputedWidth = JqLiteExtension.getComputedWidthAsFloat(iElement.next()[0]); iElement.css('width', scrollingContainerComputedWidth + 'px'); Instrumentation.log('headerRow', 'header width set', scrollingContainerComputedWidth + 'px'); }, true); }; } }; }]) .directive('row', ['ManualCompiler', 'ResizeHeightEvent', '$window', 'Debounce', 'TemplateStaticState', 'RowState', 'SortState', 'ScrollingContainerHeightState', 'JqLiteExtension', 'Instrumentation', 'ResizeWidthEvent', '$compile', function(ManualCompiler, ResizeHeightEvent, $window, Debounce, TemplateStaticState, RowState, SortState, ScrollingContainerHeightState, JqLiteExtension, Instrumentation, ResizeWidthEvent, $compile) { return { // only support elements for now to simplify the manual transclusion and replace logic. restrict: 'E', controller: ['$scope', function($scope) { $scope.sortExpression = SortState.sortExpression; $scope.handleClick = function(row, parentScopeClickHandler, selectedRowBackgroundColor) { var clickHandlerFunctionName = parentScopeClickHandler.replace('(row)', ''); if(selectedRowBackgroundColor !== 'undefined') { RowState.previouslySelectedRow.rowSelected = false; row.rowSelected = true; RowState.previouslySelectedRow = row; } if(clickHandlerFunctionName !== 'undefined') { $scope.$parent[clickHandlerFunctionName](row); } }; $scope.getRowColor = function(index, row) { if(row.rowSelected) { return TemplateStaticState.selectedRowColor; } else { if(index % 2 === 0) { return TemplateStaticState.evenRowColor; } else { return TemplateStaticState.oddRowColor; } } }; }], // manually transclude and replace the template to work around not being able to have a template with td or tr as a root element // see bug: https://github.com/angular/angular.js/issues/1459 compile: function (tElement, tAttrs) { RowState.rowSelectedBackgroundColor = tAttrs.selectedColor; ManualCompiler.compileRow(tElement, tAttrs, false); // return a linking function return function(scope, iElement) { scope.ScrollingContainerHeightState = ScrollingContainerHeightState; scope.SortState = SortState; var getHeaderComputedHeight = function() { return JqLiteExtension.getComputedHeightAsFloat(iElement.parent()[0]); }; var getScrollingContainerComputedHeight = function() { return JqLiteExtension.getComputedHeightAsFloat(angular.element(iElement.parent().children()[0])[0]); }; angular.element($window).bind('resize', Debounce.debounce(function() { // must apply since the browser resize event is not being seen by the digest process scope.$apply(function() { // flip the booleans to trigger the watches ResizeHeightEvent.fireTrigger = !ResizeHeightEvent.fireTrigger; ResizeWidthEvent.fireTrigger = !ResizeWidthEvent.fireTrigger; Instrumentation.log('row', 'debounced window resize triggered'); }); }, 50)); // set the scrolling container height event on resize // set the angularTableTableContainer height to angularTableContainer computed height - angularTableHeaderTableContainer computed height // watches get called n times until the model settles. it's typically one or two, but processing in the functions // must be idempotent and as such shouldn't rely on it being any specific number. scope.$watch('ResizeHeightEvent', function() { // pull the computed height of the header and the outer container out of the dom var outerContainerComputedHeight = getHeaderComputedHeight(); var headerComputedHeight = getScrollingContainerComputedHeight() var newScrollingContainerHeight = outerContainerComputedHeight - headerComputedHeight; if(isNaN(headerComputedHeight)) { Instrumentation.log('row', 'header computed height was NaN'); } if(isNaN(outerContainerComputedHeight)) { Instrumentation.log('row', 'outer container computed height was NaN'); } iElement.css('height', newScrollingContainerHeight + 'px'); Instrumentation.log('row', 'scrolling container height set', 'outerContainerComputedHeight: ' + outerContainerComputedHeight + '\n' + 'headerComputedHeight: ' + headerComputedHeight + '\n' + 'newScrollingContainerHeight: ' + newScrollingContainerHeight); }, true); // scroll to top when sort applied // watches get called n times until the model settles. it's typically one or two, but processing in the functions // must be idempotent and as such shouldn't rely on it being any specific number. scope.$watch('SortState', function() { iElement[0].scrollTop = 0; }, true); // check for scrollbars and adjust the header table width, and scrolling table height as needed when the number of bound rows changes scope.$watch('model', function(newValue, oldValue) { // flip the booleans to trigger the watches ResizeHeightEvent.fireTrigger = !ResizeHeightEvent.fireTrigger; ResizeWidthEvent.fireTrigger = !ResizeWidthEvent.fireTrigger; }, true); }; } }; }]) .service('Debounce', function() { var self = this; // debounce() method is slightly modified version of: // Underscore.js 1.4.4 // http://underscorejs.org // (c) 2009-2013 Jeremy Ashkenas, DocumentCloud Inc. // Underscore may be freely distributed under the MIT license. self.debounce = function(func, wait, immediate) { var timeout, result; return function() { var context = this, args = arguments, callNow = immediate && !timeout; var later = function() { timeout = null; if (!immediate) { result = func.apply(context, args); } }; clearTimeout(timeout); timeout = setTimeout(later, wait); if (callNow) { result = func.apply(context, args); } return result; }; }; return self; }) .service('JqLiteExtension', ['$window', 'Instrumentation', function($window, Instrumentation) { var self = this; // TODO: make this work with IE8<, android 3<, and ios4<: http://caniuse.com/getcomputedstyle self.getComputedPropertyAsFloat = function(rawDomElement, property) { var computedValueAsString = $window.getComputedStyle(rawDomElement).getPropertyValue(property).replace('px', ''); Instrumentation.log('JqLiteExtension', 'className: ' + rawDomElement.className + '\n' + 'property: ' + property, computedValueAsString); return parseFloat(computedValueAsString); }; self.getComputedWidthAsFloat = function(rawDomElement) { return self.getComputedPropertyAsFloat(rawDomElement, 'width'); }; self.getComputedHeightAsFloat = function(rawDomElement) { return self.getComputedPropertyAsFloat(rawDomElement, 'height'); }; return self; }]) .service('ManualCompiler', ['TemplateStaticState', function(TemplateStaticState) { var self = this; self.compileRow = function(tElement, tAttrs, isHeader) { var headerUppercase = ''; var headerDash = '' if(isHeader) { headerUppercase = 'Header'; headerDash = 'header-' } // find whatever classes were passed into the row, and merge them with the built in classes for the tr tElement.addClass('angularTable' + headerUppercase + 'Row'); // find whatever classes were passed into each column, and merge them with the built in classes for the td tElement.children().addClass('angularTable' + headerUppercase + 'Column'); if(isHeader) { angular.forEach(tElement.children(), function(childColumn, index) { if(angular.element(childColumn).attr('sortable') === 'true') { // add the ascending sort icon angular.element(childColumn).find('sort-arrow-descending').attr('ng-show', 'SortState.sortExpression == \'' + angular.element(childColumn).attr('sort-field-name') + '\' && !SortState.sortDirectionToColumnMap[\'' + angular.element(childColumn).attr('sort-field-name') + '\']').addClass('angularTableDefaultSortArrowAscending'); // add the descending sort icon angular.element(childColumn).find('sort-arrow-ascending').attr('ng-show', 'SortState.sortExpression == \'' + angular.element(childColumn).attr('sort-field-name') + '\' && SortState.sortDirectionToColumnMap[\'' + angular.element(childColumn).attr('sort-field-name') + '\']').addClass('angularTableDefaultSortArrowDescending'); // add the sort click handler angular.element(childColumn).attr('ng-click', 'setSortExpression(\'' + angular.element(childColumn).attr('sort-field-name') + '\')'); // remove the sort field name attribute from the dsl angular.element(childColumn).removeAttr('sort-field-name'); } }); } // replace row with tr if(isHeader) { var rowTemplate = tElement[0].outerHTML.replace('', '/tr>') } else { var rowTemplate = tElement[0].outerHTML.replace('', '/tr>') } // replace column with td var columnRegexString = headerDash + 'column'; var columnRegex = new RegExp(columnRegexString, "g"); rowTemplate = rowTemplate.replace(columnRegex, 'td'); if(isHeader) { rowTemplate = rowTemplate.replace(/sort-arrow-descending/g, 'div'); rowTemplate = rowTemplate.replace(/sort-arrow-ascending/g, 'div'); } else { var selectedBackgroundColor = ''; var ngClick = ''; TemplateStaticState.selectedRowColor = tAttrs.selectedColor; TemplateStaticState.evenRowColor = tAttrs.evenColor; TemplateStaticState.oddRowColor = tAttrs.oddColor; if(typeof(tAttrs.selectedColor) !== 'undefined' || typeof(tAttrs.evenColor) !== 'undefined' || typeof(tAttrs.oddColor) !== 'undefined' ) { selectedBackgroundColor = 'ng-style="{ backgroundColor: getRowColor($index, row) }"'; } if(typeof(tAttrs.onSelected) !== 'undefined') { ngClick = ' ng-click="handleClick(row, \'' + tAttrs.onSelected + '\', \'' + tAttrs.selectedColor + '\')" ' } // add the ng-repeat and row selection click handler to each row rowTemplate = rowTemplate.replace('' + rowTemplate + '
'; // replace the original template with the manually replaced and transcluded version tElement.replaceWith(rowTemplate); }; }]) .service('ResizeHeightEvent', function() { var self = this; // flip a boolean to indicate resize occured. the value of the property has no meaning. self.fireTrigger = false; return self; }) .service('ResizeWidthEvent', function() { var self = this; // flip a boolean to indicate resize occured. the value of the property has no meaning self.fireTrigger = false; return self; }) .service('ScrollingContainerHeightState', function() { var self = this; // get the padding, border and height for the outer angularTableContainer which holds the header table and the rows table self.outerContainerComputedHeight = 0; // store the offset height plus margin of the header so we know what the height of the scrolling container should be. self.headerComputedHeight = 0; return self; }) .service('TemplateStaticState', function() { var self = this; // store selected, even and odd row background colors self.selectedRowColor = ''; self.evenRowColor = ''; self.oddRowColor = ''; self.modelName = ''; return self; }) .service('RowState', function() { var self = this; // store a reference to the previously selected row so we can access it without looking it up from the bound model self.previouslySelectedRow = {}; self.previouslySelectedRowColor = ''; return self; }) .service('SortState', function() { var self = this; // store the sort expression self.sortExpression = ''; // store the columns sort direction mapping self.sortDirectionToColumnMap = {}; return self; }) .service('Instrumentation', ['TemplateStaticState', '$log', function(TemplateStaticState, $log) { var self = this; self.log = function(source, event, value) { if(TemplateStaticState.instrumentationEnabled) { $log.log('Source: ' + source); $log.log('Event: ' + event); $log.log('Value: ' + value); $log.log('------------------------\n'); } }; return self; }]);