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
+ *
+ * ```
+ *
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 =
+ '
';
+
+ $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 @@
+
+
+