diff --git a/horizon/static/angular/styles.scss b/horizon/static/angular/styles.scss index 81e674b6ba..49fd37b465 100644 --- a/horizon/static/angular/styles.scss +++ b/horizon/static/angular/styles.scss @@ -1,2 +1,3 @@ @import "help-panel/help-panel"; @import "wizard/wizard"; +@import "table/table"; \ No newline at end of file diff --git a/horizon/static/angular/table/table.js b/horizon/static/angular/table/table.js new file mode 100644 index 0000000000..2eddd321e1 --- /dev/null +++ b/horizon/static/angular/table/table.js @@ -0,0 +1,216 @@ +/* jshint globalstrict: true */ +(function() { + 'use strict'; + + /** + * @ngdoc overview + * @name hz.widget.table + * @description + * + * # hz.widget.table + * + * The `hz.widget.table` provides support for user interactions and checkbox + * selection in tables. + * + * Requires {@link https://github.com/lorenzofox3/Smart-Table `Smart-Table`} + * module and jQuery (for table drawer slide animation in IE9) to be installed. + * + * | Directives | + * |-------------------------------------------------------------------| + * | {@link hz.widget.table.directive:hzTable `hzTable`} | + * | {@link hz.widget.table.directive:hzSelectAll `hzSelectAll`} | + * | {@link hz.widget.table.directive:hzExpandDetail `hzExpandDetail`} | + * + */ + var app = angular.module('hz.widget.table', [ 'smart-table' ]); + + /** + * @ngdoc directive + * @name hz.widget.table.directive:hzTable + * @element table st-table='rowCollection' + * @description + * The `hzTable` directive extends the Smart-Table module to provide + * support for saving the checkbox selection state of each row in the + * table. Also included is the `updateSelectCount` function which + * updates the checkbox selection count of the table. + * + * @restrict A + * @scope true + * @example + * + * ``` + * + * + * + * + * + * + * + * + * + * + * + * + * + *
Name
+ * + * Foo
+ * ``` + * + */ + app.directive('hzTable', function() { + return { + restrict: 'A', + scope: true, + controller: function($scope) { + $scope.selected = {}; + $scope.numSelected = 0; + + $scope.updateSelectCount = function(row) { + if ($scope.selected.hasOwnProperty(row.id)) { + var checkedState = $scope.selected[row.id].checked; + + if (checkedState) { + $scope.numSelected += 1; + } else { + $scope.numSelected -= 1; + } + } + }; + + this.select = function(row, checkedState) { + var oldCheckedState = $scope.selected.hasOwnProperty(row.id) ? + $scope.selected[row.id].checked : + false; + + $scope.selected[row.id] = { + checked: checkedState, + item: row + }; + + if (checkedState && !oldCheckedState) { + $scope.numSelected += 1; + } else if (!checkedState && oldCheckedState) { + $scope.numSelected -= 1; + } + }; + } + }; + }); + + /** + * @ngdoc directive + * @name hz.widget.table.directive:hzSelectAll + * @element input type='checkbox' + * @description + * The `hzSelectAll` directive updates the checkbox selection state of + * every row in the table. Assign this as an attribute to a checkbox + * input element, passing in the row collection data. + * + * @restrict A + * @scope rows: '=hzSelectAll' + * @example + * + * ``` + * + * ``` + * + */ + app.directive('hzSelectAll', function() { + return { + restrict: 'A', + require: '^hzTable', + scope: { + rows: '=hzSelectAll' + }, + link: function(scope, element, attrs, hzTableCtrl) { + element.on('click', function() { + scope.$apply(function() { + var checkedState = element.prop('checked'); + angular.forEach(scope.rows, function(row) { + hzTableCtrl.select(row, checkedState); + }); + }); + }); + } + }; + }); + + /** + * @ngdoc directive + * @name hz.widget.table.directive:hzExpandDetail + * @element i class='fa fa-chevron-right' + * @param {number} duration The duration for drawer slide animation + * @description + * The `hzExpandDetail` directive toggles the detailed drawer of the row. + * The animation is implemented using jQuery's slideDown() and slideUp(). + * Assign this as an attribute to an icon that should trigger the toggle, + * passing in the two class names of the icon. If no class names are + * specified, the default 'fa-chevron-right fa-chevron-down' is used. A + * duration for the slide animation can be specified as well (default: 400). + * The detail drawer row and cell also needs to be implemented and include + * the classes 'detail-row' and 'detail', respectively. + * + * @restrict A + * @scope icons: '@hzExpandDetail', duration: '@' + * @example + * + * ``` + * + * + * + * + * + * + * + * + * ``` + * + */ + app.directive('hzExpandDetail', function() { + return { + restrict: 'A', + scope: { + icons: '@hzExpandDetail', + duration: '@' + }, + link: function(scope, element) { + element.on('click', function() { + var iconClasses = scope.icons || 'fa-chevron-right fa-chevron-down'; + element.toggleClass(iconClasses); + + var summaryRow = element.closest('tr'); + var detailCell = summaryRow.next('tr').find('.detail'); + var duration = scope.duration ? parseInt(scope.duration) : 400; + + if (summaryRow.hasClass('expanded')) { + var options = { + duration: duration, + complete: function() { + // Hide the row after the slide animation finishes + summaryRow.toggleClass('expanded'); + } + }; + + detailCell.find('.detail-expanded').slideUp(options); + } else { + summaryRow.toggleClass('expanded'); + + if (detailCell.find('.detail-expanded').length === 0) { + // Slide down animation doesn't work on table cells + // so a
wrapper needs to be added + detailCell.wrapInner('
'); + } + + detailCell.find('.detail-expanded').slideDown(duration); + } + }); + } + }; + }); + +})(); \ No newline at end of file diff --git a/horizon/static/angular/table/table.scss b/horizon/static/angular/table/table.scss new file mode 100644 index 0000000000..987b1ab305 --- /dev/null +++ b/horizon/static/angular/table/table.scss @@ -0,0 +1,300 @@ +$em-per-priority: floor($table-col-avg-width / $font-size-base) * 3; + +.table-rsp { + border-collapse: separate; + border-spacing: 0 $table-gap-height; + width: 100%; + + thead tr th, tfoot tr td { + background: none; + border: none; + padding: $table-padding; + } + + tbody tr { + &[lr-drag-src] td:not(.expander) { + cursor: move; + } + + &.lr-drop-target-before td { + border-top: $reorder-border !important; + } + + &.lr-drop-target-after td { + border-bottom: $reorder-border !important; + } + + td { + background-color: #ffffff; + border-top: $table-border; + border-bottom: $table-border; + padding: $table-padding; + position: relative; + white-space: nowrap; + z-index: 10; + + &:first-child, &.action-col { + border-left: $table-border; + } + + &:last-child, &.select-col { + border-right: $table-border; + } + } + } + + .select-col { + max-width: $select-col-width; + text-align: center; + width: $select-col-width; + } + + .action-col { + position: relative; + text-align: center; + vertical-align: top; + min-width: $batch-action-width; + width: $batch-action-width; + z-index: 20; + + .btn { + width: 100%; + } + } + + .reorder { + border-right: $table-border; + padding-right: 1.5em; + position: relative; + + &:after { + content: '\f07d'; + display: inline-block; + font-family: 'FontAwesome'; + position: absolute; + right: 0; + text-align: left; + width: 1em; + } + } + + .numeric { + text-align: right; + } + + [st-sort] { + cursor: pointer; + + &:after { + color: #d4d4d4; + content: '\f0dc'; + font-family: 'FontAwesome'; + margin-left: 0.5em; + opacity: 0; + } + + &:not(.st-sort-ascent):hover:after, &:not(.st-sort-descent):hover:after { + opacity: 1; + } + } + + .st-sort-ascent:after { + color: #000000; + content: '\f0dd'; + font-family: 'FontAwesome'; + margin-left: 0.5em; + opacity: 1; + } + + .st-sort-descent:after { + color: #000000; + content: '\f0de'; + font-family: 'FontAwesome'; + margin-left: 0.5em; + opacity: 1; + } + + &.modern { + border-spacing: 0; + + tbody tr { + td { + border: none; + border-top: $table-border; + } + + &:last-child td { + border-bottom: $table-border; + } + } + } + + &.table-detail { + border-spacing: 0; + + tbody { + tr td { + border-bottom: none; + } + + tr:last-child:not(.spacer-row) td { + border-bottom: $table-border; + } + + tr.expanded td { + border-bottom: $table-border; + + &[rowspan='2'].action-col { + border-bottom: none; + } + } + + tr.expanded:nth-last-child(-n+3) [rowspan='2'].action-col { + border-bottom: $table-border; + } + + tr:nth-last-child(2):not(.expanded) td { + border-bottom: $table-border; + } + + tr:nth-last-child(3).expanded + .detail-row + tr.spacer-row td { + border-top: none; + } + + tr + .detail-row + tr.spacer-row td { + border-top: $table-border; + } + } + + .detail-row td { + display: none; + padding: 0; + + &.detail .detail-expanded { + border-top: none; + display: none; + padding: $detail-row-padding $batch-action-padding $detail-row-padding $table-padding; + white-space: normal; + } + } + + .expanded + tr td { + border-top: none; + display: table-cell; + } + + .expander { + cursor: pointer; + max-width: $expander-width; + width: $expander-width; + } + + .spacer-row td { + background-color: inherit; + border: none; + height: $table-gap-height; + padding: 0; + position: relative; + z-index: 30; + } + + &.table-striped { + tbody { + tr { + &:nth-child(2n+1) > td, &:nth-child(2n+1) + .detail-row > td { + background-color: $table-stripe-bgcolor; + } + + &.spacer-row > td, &.spacer-row:nth-child(6n+3) + tr + tr.detail-row td, + &.detail-row:nth-child(4n+2) + tr:not(.spacer-row) td, + &.detail-row:nth-child(4n+2) + tr:not(.spacer-row) + tr.detail-row td { + background-color: transparent; + } + } + } + } + + &.modern { + .expanded + tr td { + border-top: $table-border; + } + + .expanded { + td:not(.action-col), td.action-col:not([rowspan='2']) { + border-bottom: none; + } + } + } + } + + @media only all { + .rsp-p1, .rsp-p2, + .rsp-p3, .rsp-p4 { + display: none; + } + + .rsp-alt-p1, .rsp-alt-p2, + .rsp-alt-p3, .rsp-alt-p4 { + display: inline-block; + } + } + + @media (min-width: 0em) { + $content-width: $body-min-width - $sidebar-width - (2 * $content-body-padding); + $max-priority: floor($content-width / $font-size-base / $em-per-priority); + + @for $i from 1 through $max-priority { + .rsp-p#{$i} { + display: table-cell; + } + + .rsp-alt-p#{$i} { + display: none; + } + } + } + + $p1-width: 5em + $em-per-priority * 1em; + @media (min-width: $p1-width) { + .rsp-p1 { + display: table-cell; + } + + .rsp-alt-p1 { + display: none; + } + } + + $p2-width: 5em + $em-per-priority * 2em; + @media (min-width: $p2-width) { + .rsp-p2 { + display: table-cell; + } + + .rsp-alt-p2 { + display: none; + } + } + + $p3-width: 5em + $em-per-priority * 3em; + @media (min-width: $p3-width) { + .rsp-p3 { + display: table-cell; + } + + .rsp-alt-p3 { + display: none; + } + } + + $p4-width: 5em + $em-per-priority * 4em; + @media (min-width: $p4-width) { + .rsp-p4 { + display: table-cell; + } + + .rsp-alt-p4 { + display: none; + } + } +} \ No newline at end of file diff --git a/horizon/static/angular/table/table.spec.js b/horizon/static/angular/table/table.spec.js new file mode 100644 index 0000000000..d50c9bd87d --- /dev/null +++ b/horizon/static/angular/table/table.spec.js @@ -0,0 +1,144 @@ +/* jshint browser: true, globalstrict: true */ +'use strict'; + +describe('hz.widget.table module', function() { + it('should have been defined".', function () { + expect(angular.module('hz.widget.table')).toBeDefined(); + }); +}); + +describe('table directives', function () { + + var $scope, $element; + + beforeEach(module('smart-table')); + beforeEach(module('hz')); + beforeEach(module('hz.widgets')); + beforeEach(module('hz.widget.table')); + + beforeEach(inject(function($injector) { + var $compile = $injector.get('$compile'); + $scope = $injector.get('$rootScope').$new(); + + $scope.fakeData = [ + { id: '1', animal: 'cat' }, + { id: '2', animal: 'dog' }, + { id: '3', animal: 'fish' } + ]; + + var markup = + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
Animal
{{ row.animal }}
'; + + $element = angular.element(markup); + $compile($element)($scope); + + $scope.$digest(); + })); + + describe('hzTable directive', function() { + + it('should have 3 summary rows', function() { + expect($element.find('tbody tr[ng-repeat-start]').length).toBe(3); + }); + + it('should have 3 detail rows', function() { + expect($element.find('tbody tr.detail-row').length).toBe(3); + }); + + it('should have each checkbox initially unchecked', function() { + var checkboxes = $element.find('tbody tr[ng-repeat-start] input[type="checkbox"]'); + angular.forEach(checkboxes, function(checkbox) { + expect(checkbox.checked).toBe(false); + }); + }); + + it('should have numSelected === 1 when first checkbox is clicked', function() { + var firstInput = $element.find('tbody input[type="checkbox"]').first(); + var ngModelCtrl = firstInput.controller('ngModel'); + ngModelCtrl.$setViewValue(true); + + $scope.$digest(); + + expect($element.scope().numSelected).toBe(1); + }); + + it('should have numSelected === 3 and select-all checkbox checked when all rows selected', function() { + var checkboxes = $element.find('tbody input[type="checkbox"]'); + angular.forEach(checkboxes, function(checkbox) { + checkbox.checked = true; + var ngModelCtrl = angular.element(checkbox).controller('ngModel'); + ngModelCtrl.$setViewValue(true); + }); + + $scope.$digest(); + + expect($element.scope().numSelected).toBe(3); + expect($element.find('thead input[hz-select-all]')[0].checked).toBe(true); + }); + + }); + + describe('hzSelectAll directive', function() { + + it('should select all checkboxes if select-all checkbox checked', function() { + var selectAllCb = $element.find('input[hz-select-all]').first(); + selectAllCb[0].checked = true; + selectAllCb.triggerHandler('click'); + + $scope.$digest(); + + expect($element.scope().numSelected).toBe(3); + var checkboxes = $element.find('tbody input[type="checkbox"]'); + angular.forEach(checkboxes, function(checkbox) { + expect(checkbox.checked).toBe(true); + }); + }); + + }); + + describe('hzExpandDetail directive', function() { + + it('should have summary row with class "expanded" when expanded', function() { + var expandIcon = $element.find('i.fa').first(); + expandIcon.click(); + + var summaryRow = expandIcon.closest('tr'); + expect(summaryRow.hasClass('expanded')).toBe(true); + }); + + it('should have summary row without class "expanded" when not expanded', function(done) { + var expandIcon = $element.find('i.fa').first(); + + // Click twice to mock expand and collapse + expandIcon.click(); + expandIcon.click(); + + // Wait for the slide down animation to complete before test + setTimeout(function() { + var summaryRow = expandIcon.closest('tr'); + expect(summaryRow.hasClass('expanded')).toBe(false); + done(); + }, 2000); + }); + + }); + +}); diff --git a/horizon/static/angular/widget.module.js b/horizon/static/angular/widget.module.js index 17d04c96bd..68f50e6e77 100644 --- a/horizon/static/angular/widget.module.js +++ b/horizon/static/angular/widget.module.js @@ -3,7 +3,8 @@ angular.module('hz.widgets', [ 'hz.widget.help-panel', - 'hz.widget.wizard' + 'hz.widget.wizard', + 'hz.widget.table' ]) .constant('basePath', '/static/angular/'); diff --git a/horizon/templates/horizon/_scripts.html b/horizon/templates/horizon/_scripts.html index 20d07b98fe..af375eb9bf 100644 --- a/horizon/templates/horizon/_scripts.html +++ b/horizon/templates/horizon/_scripts.html @@ -14,6 +14,7 @@ + @@ -24,6 +25,7 @@ + diff --git a/horizon/templates/horizon/jasmine/jasmine.html b/horizon/templates/horizon/jasmine/jasmine.html index 2567c59007..131d4c7fa8 100644 --- a/horizon/templates/horizon/jasmine/jasmine.html +++ b/horizon/templates/horizon/jasmine/jasmine.html @@ -10,10 +10,13 @@ + + +