From 9fd0184131493da23a4d4656478c116a9667bcc2 Mon Sep 17 00:00:00 2001 From: Joe Fiorini Date: Tue, 24 Mar 2015 17:06:55 -0400 Subject: [PATCH 1/3] Implement multi-select-list module --- .../list-generator/list-generator.factory.js | 174 ++++++++++++------ .../static/js/shared/list-generator/main.js | 3 +- .../js/shared/multi-select-list/main.js | 9 + .../multi-select-list.controller.js | 85 +++++++++ .../multi-select-list.directive.js | 11 ++ .../multi-select-list/select-all.directive.js | 62 +++++++ .../multi-select-list/select-all.partial.html | 18 ++ .../select-list-item.directive.js | 33 ++++ awx/ui/static/less/lists.less | 19 ++ 9 files changed, 361 insertions(+), 53 deletions(-) create mode 100644 awx/ui/static/js/shared/multi-select-list/main.js create mode 100644 awx/ui/static/js/shared/multi-select-list/multi-select-list.controller.js create mode 100644 awx/ui/static/js/shared/multi-select-list/multi-select-list.directive.js create mode 100644 awx/ui/static/js/shared/multi-select-list/select-all.directive.js create mode 100644 awx/ui/static/js/shared/multi-select-list/select-all.partial.html create mode 100644 awx/ui/static/js/shared/multi-select-list/select-list-item.directive.js diff --git a/awx/ui/static/js/shared/list-generator/list-generator.factory.js b/awx/ui/static/js/shared/list-generator/list-generator.factory.js index d0a5514d24..da225ecbef 100644 --- a/awx/ui/static/js/shared/list-generator/list-generator.factory.js +++ b/awx/ui/static/js/shared/list-generator/list-generator.factory.js @@ -40,8 +40,8 @@ export default ['$location', '$compile', '$rootScope', 'SearchWidget', 'Paginate // hdr: // // Inject into a custom element using options.id: - // Control breadcrumb creation with options.breadCrumbs: // + // Control breadcrumb creation with options.breadCrumbs: var element; if (options.id) { @@ -115,6 +115,31 @@ export default ['$location', '$compile', '$rootScope', 'SearchWidget', 'Paginate } }; + if (list.multiSelect) { + var cleanupSelectionChanged = + this.scope.$on('multiSelectList.selectionChanged', function(e, selection) { + this.scope.selectedItems = selection.selectedItems; + + // Track the selected state of each item + // for changing the row class based on the + // selection + // + selection.selectedItems.forEach(function(item) { + item.isSelected = true; + }); + + selection.deselectedItems.forEach(function(item) { + item.isSelected = false; + }); + + }.bind(this)); + + this.scope.$on('$destroy', function() { + cleanupSelectionChanged(); + }); + + } + $compile(element)(this.scope); // Reset the scope to prevent displaying old data from our last visit to this list @@ -294,34 +319,62 @@ export default ['$location', '$compile', '$rootScope', 'SearchWidget', 'Paginate html += "
') + .attr('id', list.name + '_table') + .addClass('table table-condensed') + .addClass(extraClasses) + .attr('multi-select-list', multiSelect); + + } + + var table = buildTable(); + var innerTable = ''; if (!options.skipTableHead) { - html += this.buildHeader(options); + innerTable += this.buildHeader(options); } // table body - html += "\n"; - html += "\n"; - if (list.index) { - html += "{{ $index + ((" + list.iterator + "_page - 1) * " + list.iterator + "_page_size) + 1 }}.\n"; + innerTable += "\n"; + innerTable += "\n"; + + if (list.index) { + innerTable += "{{ $index + ((" + list.iterator + "_page - 1) * " + list.iterator + "_page_size) + 1 }}.\n"; + } + + if (list.multiSelect) { + innerTable += ''; + } + cnt = 2; base = (list.base) ? list.base : list.name; base = base.replace(/^\//, ''); @@ -329,7 +382,7 @@ export default ['$location', '$compile', '$rootScope', 'SearchWidget', 'Paginate cnt++; if ((list.fields[fld].searchOnly === undefined || list.fields[fld].searchOnly === false) && !(options.mode === 'lookup' && list.fields[fld].excludeModal === true)) { - html += Column({ + innerTable += Column({ list: list, fld: fld, options: options, @@ -340,12 +393,12 @@ export default ['$location', '$compile', '$rootScope', 'SearchWidget', 'Paginate if (options.mode === 'select' || options.mode === 'lookup') { if(options.input_type==="radio"){ //added by JT so that lookup forms can be either radio inputs or check box inputs - html += ""; } else { // its assumed that options.input_type = checkbox - html += ""; } @@ -353,12 +406,12 @@ export default ['$location', '$compile', '$rootScope', 'SearchWidget', 'Paginate // Row level actions - html += ""; + innerTable += ""; for (field_action in list.fieldActions) { if (field_action !== 'columnClass') { if (list.fieldActions[field_action].type && list.fieldActions[field_action].type === 'DropDown') { - html += DropDown({ + innerTable += DropDown({ list: list, fld: field_action, options: options, @@ -368,53 +421,56 @@ export default ['$location', '$compile', '$rootScope', 'SearchWidget', 'Paginate }); } else { fAction = list.fieldActions[field_action]; - html += ""; + innerTable += ""; } else { - html += SelectIcon({ + innerTable += SelectIcon({ action: field_action }); } //html += (fAction.label) ? " " + list.fieldActions[field_action].label + // "" : ""; - html += ""; + innerTable += ""; } } } - html += "\n"; + innerTable += "\n"; } - html += "\n"; + innerTable += "\n"; // Message for when a collection is empty - html += "\n"; - html += "
No records matched your search.
\n"; - html += "\n"; + innerTable += "\n"; + innerTable += "
No records matched your search.
\n"; + innerTable += "\n"; // Message for loading - html += "\n"; - html += "
Loading...
\n"; - html += "\n"; + innerTable += "\n"; + innerTable += "
Loading...
\n"; + innerTable += "\n"; // End List - html += "\n"; - html += "\n"; + innerTable += "\n"; + + table.html(innerTable); + html += table.prop('outerHTML'); + html += "
\n"; if (options.mode === 'select' && (options.selectButton === undefined || options.selectButton)) { @@ -447,6 +503,15 @@ export default ['$location', '$compile', '$rootScope', 'SearchWidget', 'Paginate var list = this.list, fld, html; + function buildSelectAll() { + return $('') + .addClass('col-xs-1 select-column') + .append( + $('') + .attr('selections-empty', 'selectedItems.length === 0') + .attr('items-length', list.name + '.length')); + } + if (options === undefined) { options = this.options; } @@ -456,6 +521,11 @@ export default ['$location', '$compile', '$rootScope', 'SearchWidget', 'Paginate if (list.index) { html += "#\n"; } + + if (list.multiSelect) { + html += buildSelectAll().prop('outerHTML'); + } + for (fld in list.fields) { if ((list.fields[fld].searchOnly === undefined || list.fields[fld].searchOnly === false) && !(options.mode === 'lookup' && list.fields[fld].excludeModal === true)) { diff --git a/awx/ui/static/js/shared/list-generator/main.js b/awx/ui/static/js/shared/list-generator/main.js index 1af299d520..85b21247eb 100644 --- a/awx/ui/static/js/shared/list-generator/main.js +++ b/awx/ui/static/js/shared/list-generator/main.js @@ -1,8 +1,9 @@ import generateList from './list-generator.factory'; import toolbarButton from './toolbar-button.directive'; import generatorHelpers from 'tower/shared/generator-helpers'; +import multiSelectList from 'tower/shared/multi-select-list/main'; export default - angular.module('listGenerator', [generatorHelpers.name]) + angular.module('listGenerator', [generatorHelpers.name, multiSelectList.name]) .factory('generateList', generateList) .directive('toolbarButton', toolbarButton); diff --git a/awx/ui/static/js/shared/multi-select-list/main.js b/awx/ui/static/js/shared/multi-select-list/main.js new file mode 100644 index 0000000000..41dbe0d572 --- /dev/null +++ b/awx/ui/static/js/shared/multi-select-list/main.js @@ -0,0 +1,9 @@ +import multiSelect from './multi-select-list.directive'; +import selectAll from './select-all.directive'; +import selectListItem from './select-list-item.directive'; + +export default + angular.module('multiSelectList', []) + .directive('multiSelectList', multiSelect) + .directive('selectAll', selectAll) + .directive('selectListItem', selectListItem); diff --git a/awx/ui/static/js/shared/multi-select-list/multi-select-list.controller.js b/awx/ui/static/js/shared/multi-select-list/multi-select-list.controller.js new file mode 100644 index 0000000000..f7a8c52849 --- /dev/null +++ b/awx/ui/static/js/shared/multi-select-list/multi-select-list.controller.js @@ -0,0 +1,85 @@ +export default ['$scope', + function ($scope) { + $scope.items = []; + $scope.selection = { + isExtended: false, + selectedItems: [], + deselectedItems: [] + }; + + Object.defineProperty($scope.selection, + 'length', + { get: function() { + return this.items.length; + } + }); + + function rebuildSelections() { + var _items = _($scope.items).chain(); + + $scope.selection.selectedItems = + _items.filter(function(item) { + return item.isSelected; + }).pluck('value').value(); + + $scope.selection.deselectedItems = + _items.pluck('value').difference($scope.selection.selectedItems) + .value(); + + $scope.$emit('multiSelectList.selectionChanged', $scope.selection); + } + + this.registerItem = function(item) { + var decoratedItem = this.decorateItem(item); + $scope.items = $scope.items.concat(decoratedItem); + return decoratedItem; + }; + + this.deregisterItem = function(leavingItem) { + $scope.items = $scope.items.filter(function(item) { + return leavingItem !== item; + }); + rebuildSelections(); + }; + + this.decorateItem = function(item) { + return { + isSelected: false, + value: item + }; + }; + + this.selectAll = function() { + $scope.items.forEach(function(item) { + item.isSelected = true; + }); + }; + + this.deselectAll = function() { + $scope.items.forEach(function(item) { + item.isSelected = false; + }); + $scope.selection.isExtended = false; + rebuildSelections(); + }; + + + this.deselectAllExtended = function(extendedLength) { + $scope.selection.isExtended = false; + }; + + this.selectAllExtended = function(extendedLength) { + $scope.selection.isExtended = true; + }; + + this.selectItem = function(item) { + item.isSelected = true; + rebuildSelections(); + }; + + this.deselectItem = function(item) { + item.isSelected = false; + rebuildSelections(); + }; + + }]; diff --git a/awx/ui/static/js/shared/multi-select-list/multi-select-list.directive.js b/awx/ui/static/js/shared/multi-select-list/multi-select-list.directive.js new file mode 100644 index 0000000000..0f80b99433 --- /dev/null +++ b/awx/ui/static/js/shared/multi-select-list/multi-select-list.directive.js @@ -0,0 +1,11 @@ +import controller from './multi-select-list.controller'; + +export default + [ function() { + return { + restrict: 'A', + scope: { + }, + controller: controller + }; + }]; diff --git a/awx/ui/static/js/shared/multi-select-list/select-all.directive.js b/awx/ui/static/js/shared/multi-select-list/select-all.directive.js new file mode 100644 index 0000000000..2c7fa8fc1b --- /dev/null +++ b/awx/ui/static/js/shared/multi-select-list/select-all.directive.js @@ -0,0 +1,62 @@ +// TODO: Extract to its own helper +// Example: +// template('shared/multi-select-list/select-all') +// // => +// '/static/js/shared/multi-select-list/select-all.html +// +function template(base) { + return '/static/js/' + base + '.partial.html'; +} + +export default + [ function() { + return { + require: '^multiSelectList', + restrict: 'E', + scope: { + label: '@', + itemsLength: '=', + extendedItemsLength: '=', + isSelectionExtended: '=', + isSelectionEmpty: '=selectionsEmpty' + }, + templateUrl: template('shared/multi-select-list/select-all'), + link: function(scope, element, attrs, controller) { + + scope.label = scope.label || 'All'; + scope.selectExtendedLabel = scope.extendedLabel || 'Select all ' + scope.extendedItemsLength + ' items'; + scope.deselectExtendedLabel = scope.deselectExtendedLabel || 'Deselect extra items'; + + scope.doSelectAll = function(e) { + if (scope.isSelected) { + controller.selectAll(); + + if (scope.supportsExtendedItems) { + scope.showExtendedMessage = scope.itemsLength !== scope.extendedItemsLength; + } + } else { + controller.deselectAll(); + } + }; + + scope.$watch('extendedItemsLength', function(value) { + scope.supportsExtendedItems = _.isNumber(value); + }); + + scope.$watch('isSelectionEmpty', function(value) { + if (value) { + scope.isSelected = false; + } + }); + + scope.selectAllExtended = function() { + controller.selectAllExtended(scope.extendedItemsLength); + }; + + scope.deselectAllExtended = function() { + controller.deselectAllExtended(scope.extendedItemsLength); + }; + + } + }; + }]; diff --git a/awx/ui/static/js/shared/multi-select-list/select-all.partial.html b/awx/ui/static/js/shared/multi-select-list/select-all.partial.html new file mode 100644 index 0000000000..9b5e62a3f9 --- /dev/null +++ b/awx/ui/static/js/shared/multi-select-list/select-all.partial.html @@ -0,0 +1,18 @@ + + + diff --git a/awx/ui/static/js/shared/multi-select-list/select-list-item.directive.js b/awx/ui/static/js/shared/multi-select-list/select-list-item.directive.js new file mode 100644 index 0000000000..ecba972cc9 --- /dev/null +++ b/awx/ui/static/js/shared/multi-select-list/select-list-item.directive.js @@ -0,0 +1,33 @@ +export default + [ function() { + return { + restrict: 'E', + scope: { + item: '=item' + }, + require: '^multiSelectList', + template: '', + link: function(scope, element, attrs, multiSelectList) { + + scope.isSelected = false; + scope.decoratedItem = multiSelectList.registerItem(scope.item); + + scope.$watch('isSelected', function(value) { + if (value === true) { + multiSelectList.selectItem(scope.decoratedItem); + } else if (value === false) { + multiSelectList.deselectItem(scope.decoratedItem); + } + }); + + scope.$watch('decoratedItem.isSelected', function(value) { + scope.isSelected = value; + }); + + scope.$on('$destroy', function() { + multiSelectList.deregisterItem(scope.decoratedItem); + }); + + } + }; + }]; diff --git a/awx/ui/static/less/lists.less b/awx/ui/static/less/lists.less index afa2514006..cfd0615ba8 100644 --- a/awx/ui/static/less/lists.less +++ b/awx/ui/static/less/lists.less @@ -14,3 +14,22 @@ font-weight: normal; } } + +.is-selected-row, .is-selected-row td { + background-color: #E4F1FF !important; +} + +.select-column { + text-align: center; +} + +th.select-column { + label { + // overrides the default style of + // display: inline-block, which allowed + // margins to push down the label, + // thus breaking the vertical-alignment + // of the cell + display: inline; + } +} From 4122be021158cd4a6000a48f040ab56d2a255466 Mon Sep 17 00:00:00 2001 From: Joe Fiorini Date: Wed, 18 Mar 2015 11:22:05 -0400 Subject: [PATCH 2/3] Document multi-select-list directive --- awx/ui/static/js/docs.js | 2 +- awx/ui/static/js/forms/CustomInventory.js | 2 +- awx/ui/static/js/helpers/ConfigureTower.js | 2 +- awx/ui/static/js/helpers/CustomInventory.js | 2 +- awx/ui/static/js/helpers/Survey.js | 2 +- .../multi-select-list.controller.js | 109 ++++++++++++++- .../multi-select-list.directive.js | 71 +++++++++- .../multi-select-list/select-all.directive.js | 131 +++++++++++++++++- .../multi-select-list/select-all.partial.html | 2 +- .../select-list-item.directive.js | 18 +++ awx/ui/static/js/widgets/PortalJobs.js | 2 +- 11 files changed, 331 insertions(+), 12 deletions(-) diff --git a/awx/ui/static/js/docs.js b/awx/ui/static/js/docs.js index 9398fde08c..8f51c66bf2 100644 --- a/awx/ui/static/js/docs.js +++ b/awx/ui/static/js/docs.js @@ -1 +1 @@ -import 'tower/debug'; +import 'tower/shared/multi-select-list/main.js'; diff --git a/awx/ui/static/js/forms/CustomInventory.js b/awx/ui/static/js/forms/CustomInventory.js index 45f2279781..df9aaf9f23 100644 --- a/awx/ui/static/js/forms/CustomInventory.js +++ b/awx/ui/static/js/forms/CustomInventory.js @@ -8,7 +8,7 @@ */ /** * @ngdoc function - * @name forms.function:Organizations + * @name forms.function:CustomInventory * @description This form is for adding/editing an organization */ diff --git a/awx/ui/static/js/helpers/ConfigureTower.js b/awx/ui/static/js/helpers/ConfigureTower.js index 3bda266ca9..15142da257 100644 --- a/awx/ui/static/js/helpers/ConfigureTower.js +++ b/awx/ui/static/js/helpers/ConfigureTower.js @@ -3,7 +3,7 @@ */ /** * @ngdoc function - * @name helpers.function:Schedules + * @name helpers.function:ConfigureTower * @description * Schedules Helper * diff --git a/awx/ui/static/js/helpers/CustomInventory.js b/awx/ui/static/js/helpers/CustomInventory.js index 6ca3ba4834..b5a7167efb 100644 --- a/awx/ui/static/js/helpers/CustomInventory.js +++ b/awx/ui/static/js/helpers/CustomInventory.js @@ -3,7 +3,7 @@ */ /** * @ngdoc function - * @name helpers.function:Schedules + * @name helpers.function:CustomInventory * @description * Schedules Helper * diff --git a/awx/ui/static/js/helpers/Survey.js b/awx/ui/static/js/helpers/Survey.js index 06984e0402..b41b345eba 100644 --- a/awx/ui/static/js/helpers/Survey.js +++ b/awx/ui/static/js/helpers/Survey.js @@ -3,7 +3,7 @@ */ /** * @ngdoc function - * @name helpers.function:Schedules + * @name helpers.function:Survey * @description * Schedules Helper * diff --git a/awx/ui/static/js/shared/multi-select-list/multi-select-list.controller.js b/awx/ui/static/js/shared/multi-select-list/multi-select-list.controller.js index f7a8c52849..d55bd2e2e9 100644 --- a/awx/ui/static/js/shared/multi-select-list/multi-select-list.controller.js +++ b/awx/ui/static/js/shared/multi-select-list/multi-select-list.controller.js @@ -1,3 +1,12 @@ +/** + * @ngdoc object + * @name multiSelectList.controller:multiSelectList + * + * @description + * + * `multiSelectList` controller provides the API for the {@link multiSelectList.directive:multiSelectList `multiSelectList`} directive. The controller contains methods for selecting/deselecting items, controlling the extended selection, registering items to be selectable and emitting an event on the directive's `$scope`. + * + */ export default ['$scope', function ($scope) { $scope.items = []; @@ -7,10 +16,11 @@ export default ['$scope', deselectedItems: [] }; + // Makes $scope.selection.length an alias for $scope.selectedItems.length Object.defineProperty($scope.selection, 'length', { get: function() { - return this.items.length; + return this.selectedItems.length; } }); @@ -26,15 +36,41 @@ export default ['$scope', _items.pluck('value').difference($scope.selection.selectedItems) .value(); + /** + * + * @ngdoc event + * @name multiSelectList.selectionChanged + * @eventOf multiSelectList.directive:multiSelectList + * + */ $scope.$emit('multiSelectList.selectionChanged', $scope.selection); } + /** + * @ngdoc + * @name multiSelectList.controller:multiSelectList#registerItem + * @methodOf multiSelectList.controller:multiSelectList + * + * @description + * Prepares an object to be tracked in the select list. Returns the + * decorated item created by + * {@link multiSelectList.controller:multiSelectList#decorateItem `decorateItem`} + */ this.registerItem = function(item) { var decoratedItem = this.decorateItem(item); $scope.items = $scope.items.concat(decoratedItem); return decoratedItem; }; + /** + * @ngdoc + * @name multiSelectList.controller:multiSelectList#deregisterItem + * @methodOf multiSelectList.controller:multiSelectList + * + * @description + * Removes an item from the list; called if the item is removed from the display + * so that it is no longer tracked as a selectable item. + */ this.deregisterItem = function(leavingItem) { $scope.items = $scope.items.filter(function(item) { return leavingItem !== item; @@ -42,6 +78,18 @@ export default ['$scope', rebuildSelections(); }; + /** + * @ngdoc + * @name multiSelectList.controller:multiSelectList#decorateItem + * @methodOf multiSelectList.controller:multiSelectList + * + * @description + * + * This decorates an item with an object that has an `isSelected` property. + * This value is used to determine the lists of selected and non-selected + * items to emit with the `multiSelectList.selectionChanged` + * event. + */ this.decorateItem = function(item) { return { isSelected: false, @@ -49,12 +97,31 @@ export default ['$scope', }; }; + /** + * @ngdoc + * @name multiSelectList.controller:multiSelectList#selectAll + * @methodOf multiSelectList.controller:multiSelectList + * + * @description + * Marks all items in the list as selected. + * Triggers {@link multiSelectList.selectionChanged `multiSelectList.selectionChanged`} + */ this.selectAll = function() { $scope.items.forEach(function(item) { item.isSelected = true; }); + rebuildSelections(); }; + /** + * @ngdoc + * @name multiSelectList.controller:multiSelectList#deselectAll + * @methodOf multiSelectList.controller:multiSelectList + * + * @description + * Marks all items in the list as not selected. + * Triggers {@link multiSelectList.selectionChanged `multiSelectList.selectionChanged`} + */ this.deselectAll = function() { $scope.items.forEach(function(item) { item.isSelected = false; @@ -64,19 +131,59 @@ export default ['$scope', }; + /** + * @ngdoc + * @name multiSelectList.controller:multiSelectList#deselectAllExtended + * @methodOf multiSelectList.controller:multiSelectList + * + * @description + * Disables extended selection. + * Triggers {@link multiSelectList.selectionChanged `multiSelectList.selectionChanged`} + */ this.deselectAllExtended = function(extendedLength) { $scope.selection.isExtended = false; + rebuildSelections(); }; + /** + * @ngdoc + * @name multiSelectList.controller:multiSelectList#selectAllExtended + * @methodOf multiSelectList.controller:multiSelectList + * + * @description + * Enables extended selection. + * Triggers {@link multiSelectList.selectionChanged `multiSelectList.selectionChanged`} + */ this.selectAllExtended = function(extendedLength) { $scope.selection.isExtended = true; + rebuildSelections(); }; + /** + * @ngdoc + * @name multiSelectList.controller:multiSelectList#selectItem + * @methodOf multiSelectList.controller:multiSelectList + * + * @description + * Marks an item as selected. + * Triggers {@link multiSelectList.selectionChanged `multiSelectList.selectionChanged`} + * + */ this.selectItem = function(item) { item.isSelected = true; rebuildSelections(); }; + /** + * @ngdoc + * @name multiSelectList.controller:multiSelectList#deregisterItem + * @methodOf multiSelectList.controller:multiSelectList + * + * @description + * Marks an item as not selected. + * Triggers {@link multiSelectList.selectionChanged `multiSelectList.selectionChanged`} + * + */ this.deselectItem = function(item) { item.isSelected = false; rebuildSelections(); diff --git a/awx/ui/static/js/shared/multi-select-list/multi-select-list.directive.js b/awx/ui/static/js/shared/multi-select-list/multi-select-list.directive.js index 0f80b99433..f65d8a2f4d 100644 --- a/awx/ui/static/js/shared/multi-select-list/multi-select-list.directive.js +++ b/awx/ui/static/js/shared/multi-select-list/multi-select-list.directive.js @@ -1,8 +1,75 @@ +/** + * @ngdoc overview + * @name multiSelectList + * @scope + * @description Does some stuff + * + * @ngdoc directive + * @name multiSelectList.directive:multiSelectList + * @description + * The `multiSelectList` directive works in conjunction with the + * `selectListItem` and (optionally) the `selectAll` directives to + * render checkboxes with list items and tracking the selected state + * of each item. The `selectListItem` directive renders a checkbox, + * and the `multiSelectList` directive tracks the selected state + * of list items. The `selectAll` directive renders a checkbox that + * will select/deselect all items in the list. + * + * + * This directive exposes a special object on its local scope called + * `selection` that is used to access the current selection state. + * The following properties on `selection` are available: + * + * | Property | Type | Details | + * |-------------------|-----------------|-------------------------------------------------------------| + * | `selectedItems` | {@type array} | The items that are currently selected | + * | `deselectedItem` | {@type array} | The items that are currently _not_ selected | + * | `isExtended` | {@type boolean} | Indicates that the user has requested an extended selection | + * | `length` | {@type number} | The length of the selected items array | + * + * Use the `multi-select-list` directive to indicate that you want + * to allow users to select items in a list. To display a checkbox + * next to each item, use the {@link multiSelectList.directive:selectListItem `select-list-item`} directive. + * + * # Rendering a basic multi-select list + * + * @example + * + * This example creates a list of names and then + * uses `multiSelectList` to make the names + * selectable: + * + + +
+
    +
  • + + {{item.name}} +
  • +
+
+
+ +
+ * +*/ import controller from './multi-select-list.controller'; export default - [ function() { - return { +[ function() { + return { restrict: 'A', scope: { }, diff --git a/awx/ui/static/js/shared/multi-select-list/select-all.directive.js b/awx/ui/static/js/shared/multi-select-list/select-all.directive.js index 2c7fa8fc1b..bbeee0e5bd 100644 --- a/awx/ui/static/js/shared/multi-select-list/select-all.directive.js +++ b/awx/ui/static/js/shared/multi-select-list/select-all.directive.js @@ -1,3 +1,122 @@ +/** + * @ngdoc directive + * @name multiSelectList.directive:selectAll + * @scope + * @restrict E + * + * @param {string} label The text that will appear next to the checkbox + * @param {number} itemsLength The number of displayed items in the list + * @param {number} extendedItemsLength The total number of items in the list used for extended mode (see below) + * @param {string_expression} extendedLabel A custom label to display when prompting the user to extend the selection; this is an expression so strings must be in single quotes ('), but you can use scope varibles here to display the count of items with the `extendedItemsLength` property + * @param {boolean_expression} selectionsEmpty An expression that evaluates to a truthy value used to disable + * the select all checkbox when the displayed list is empty + * + * @description + * + * Use the `select-all` directive as a child of a `multi-select-list` + * to present the user with a checkbox that, when checked, checks all + * `select-list-item` children, and when unchecked it unchecks them. + * + * + +
+
    +
  • + +
  • +
  • + + {{item.name}} +
  • +
+
+
+ + *
+ * + * ## Extended Selections + * + * In some cases the list items you are displaying are only a subset of + * a larger list (eg. using pagination or infinite scroll to seperate + * items). In these cases, when a user checks "select all", it may be + * useful to give them the option to also select all the remaining + * items in the list. + * + * This behavior is controlled by the `extendedItemsLength` property + * of this directive. Set it to the total length of items in the list. + * For example, if you have a list of 100 items, displayed 10 per page, + * then `itemsLength` would be 10 and `extendedItemsLength` would be 100. + * When the user checks "select all" in the above example, it will show + * a button prompting them to "Select all 100 items". When the user selects + * this option, the `select-all` directive tells the `multiSelectList` + * controller that the selection is "extended" to all the items in the list. + * Listeners to the `multiSelectList.selectionChanged` event can then use this + * flag to respond differently when all items are selected. + * + * + * + + angular.module('extendedSelectionExample', ['multiSelectList']) + .controller('namesController', ['$scope', function($scope) { + + var cleanup = $scope.$on('multiSelectList.selectionChanged', function(e, selection) { + $scope.isSelectionExtended = selection.isExtended; + }); + + $scope.$on('$destroy', cleanup); + + $scope.allNames = + [ { name: 'John' + }, + { name: 'Jared' + }, + { name: 'Joe' + }, + { name: 'James' + }, + { name: 'Matt' + }, + { name: 'Luke' + }, + { name: 'Chris' + } + ]; + + $scope.firstPageOfNames = + $scope.allNames.slice(0,3); + }]); + + +
+

Extended Selection

+
    +
  • + +
  • +
  • + + {{item.name}} +
  • +
+
+
+ *
+ */ // TODO: Extract to its own helper // Example: // template('shared/multi-select-list/select-all') @@ -17,14 +136,14 @@ export default label: '@', itemsLength: '=', extendedItemsLength: '=', - isSelectionExtended: '=', + extendedLabel: '&', isSelectionEmpty: '=selectionsEmpty' }, templateUrl: template('shared/multi-select-list/select-all'), link: function(scope, element, attrs, controller) { scope.label = scope.label || 'All'; - scope.selectExtendedLabel = scope.extendedLabel || 'Select all ' + scope.extendedItemsLength + ' items'; + scope.selectExtendedLabel = scope.extendedLabel() || 'Select all ' + scope.extendedItemsLength + ' items'; scope.deselectExtendedLabel = scope.deselectExtendedLabel || 'Deselect extra items'; scope.doSelectAll = function(e) { @@ -36,6 +155,12 @@ export default } } else { controller.deselectAll(); + + if (scope.isSelectionExtended) { + scope.deselectAllExtended(); + } + + scope.showExtendedMessage = false; } }; @@ -51,10 +176,12 @@ export default scope.selectAllExtended = function() { controller.selectAllExtended(scope.extendedItemsLength); + scope.isSelectionExtended = true; }; scope.deselectAllExtended = function() { controller.deselectAllExtended(scope.extendedItemsLength); + scope.isSelectionExtended = false; }; } diff --git a/awx/ui/static/js/shared/multi-select-list/select-all.partial.html b/awx/ui/static/js/shared/multi-select-list/select-all.partial.html index 9b5e62a3f9..8cf597c10c 100644 --- a/awx/ui/static/js/shared/multi-select-list/select-all.partial.html +++ b/awx/ui/static/js/shared/multi-select-list/select-all.partial.html @@ -13,6 +13,6 @@ diff --git a/awx/ui/static/js/shared/multi-select-list/select-list-item.directive.js b/awx/ui/static/js/shared/multi-select-list/select-list-item.directive.js index ecba972cc9..726fb044a0 100644 --- a/awx/ui/static/js/shared/multi-select-list/select-list-item.directive.js +++ b/awx/ui/static/js/shared/multi-select-list/select-list-item.directive.js @@ -1,3 +1,21 @@ +/** + * @ngdoc directive + * @name multiSelectList.directive:selectListItem + * @restrict E + * @scope + * @description + * + The `select-list-item` directive renders a checkbox for tracking + the state of a given item in a list. When the user checks the + checkbox it tells the `multi-select-list` controller to select + the item; when the user unchecks the checkbox it tells the controller + to deselect the item. + + @example + + For examples of using this directive, see {@link multiSelectList.directive:multiSelectList multiSelectList}. + + */ export default [ function() { return { diff --git a/awx/ui/static/js/widgets/PortalJobs.js b/awx/ui/static/js/widgets/PortalJobs.js index ffd8005aba..b51a388a7d 100644 --- a/awx/ui/static/js/widgets/PortalJobs.js +++ b/awx/ui/static/js/widgets/PortalJobs.js @@ -3,7 +3,7 @@ */ /** * @ngdoc function - * @name widgets.function:DashboardJobs + * @name widgets.function:PortalJobs * @description * */ From 98ea258a9b45baa9979c0a5860ba1d7ba86298da Mon Sep 17 00:00:00 2001 From: Joe Fiorini Date: Mon, 23 Mar 2015 16:58:00 -0400 Subject: [PATCH 3/3] Add tests for multi-select-list directive --- .../multi-select-list.controller.js | 4 +- .../multi-select-list/select-all.directive.js | 2 +- awx/ui/tests/karma.conf.js | 3 +- awx/ui/tests/unit/describe-module.js | 16 +- .../multi-select-list.controller-test.js | 0 .../multi-select-list.directive-test.js | 197 ++++++++++++++++++ .../select-all.directive-test.js | 94 +++++++++ 7 files changed, 309 insertions(+), 7 deletions(-) create mode 100644 awx/ui/tests/unit/multi-select-list/multi-select-list.controller-test.js create mode 100644 awx/ui/tests/unit/multi-select-list/multi-select-list.directive-test.js create mode 100644 awx/ui/tests/unit/multi-select-list/select-all.directive-test.js diff --git a/awx/ui/static/js/shared/multi-select-list/multi-select-list.controller.js b/awx/ui/static/js/shared/multi-select-list/multi-select-list.controller.js index d55bd2e2e9..0af01b9eb8 100644 --- a/awx/ui/static/js/shared/multi-select-list/multi-select-list.controller.js +++ b/awx/ui/static/js/shared/multi-select-list/multi-select-list.controller.js @@ -140,7 +140,7 @@ export default ['$scope', * Disables extended selection. * Triggers {@link multiSelectList.selectionChanged `multiSelectList.selectionChanged`} */ - this.deselectAllExtended = function(extendedLength) { + this.deselectAllExtended = function() { $scope.selection.isExtended = false; rebuildSelections(); }; @@ -154,7 +154,7 @@ export default ['$scope', * Enables extended selection. * Triggers {@link multiSelectList.selectionChanged `multiSelectList.selectionChanged`} */ - this.selectAllExtended = function(extendedLength) { + this.selectAllExtended = function() { $scope.selection.isExtended = true; rebuildSelections(); }; diff --git a/awx/ui/static/js/shared/multi-select-list/select-all.directive.js b/awx/ui/static/js/shared/multi-select-list/select-all.directive.js index bbeee0e5bd..57e25eae80 100644 --- a/awx/ui/static/js/shared/multi-select-list/select-all.directive.js +++ b/awx/ui/static/js/shared/multi-select-list/select-all.directive.js @@ -146,7 +146,7 @@ export default scope.selectExtendedLabel = scope.extendedLabel() || 'Select all ' + scope.extendedItemsLength + ' items'; scope.deselectExtendedLabel = scope.deselectExtendedLabel || 'Deselect extra items'; - scope.doSelectAll = function(e) { + scope.doSelectAll = function() { if (scope.isSelected) { controller.selectAll(); diff --git a/awx/ui/tests/karma.conf.js b/awx/ui/tests/karma.conf.js index 62321c9ba0..a6f42aa4a6 100644 --- a/awx/ui/tests/karma.conf.js +++ b/awx/ui/tests/karma.conf.js @@ -18,7 +18,8 @@ module.exports = function(config) { [ 'mocha', 'chai', 'sinon-chai', - 'chai-as-promised' + 'chai-as-promised', + 'chai-things' ], preprocessors: diff --git a/awx/ui/tests/unit/describe-module.js b/awx/ui/tests/unit/describe-module.js index 47b7845a74..b230edb136 100644 --- a/awx/ui/tests/unit/describe-module.js +++ b/awx/ui/tests/unit/describe-module.js @@ -131,6 +131,7 @@ function TestDirective(name, deps) { afterCompile: function(fn) { var self = this; + var $outerScope; // Make sure compile step gets setup first if (!this._compileRegistered) { @@ -140,7 +141,7 @@ function TestDirective(name, deps) { // Then pre-apply the function with the outer scope self.withScope(function($scope) { // `this` refers to mocha test suite - fn = fn.bind(this, $scope); + $outerScope = $scope; }); // Finally, have it called by the isolate scope @@ -150,7 +151,7 @@ function TestDirective(name, deps) { // self.withIsolateScope(function($scope) { // `this` refers to mocha test suite - fn.apply(this, [$scope]); + fn.apply(this, [$outerScope, $scope]); }); }, @@ -164,7 +165,11 @@ function TestDirective(name, deps) { } beforeEach("compile directive element", - inject(['$compile', '$httpBackend', function($compile, $httpBackend) { + inject(['$compile', '$httpBackend', '$rootScope', function($compile, $httpBackend, $rootScope) { + + if (!self.$scope) { + self.$scope = $rootScope.$new(); + } self.$element = $compile(self.element)(self.$scope); $(self.$element).appendTo('body'); @@ -200,6 +205,11 @@ function TestDirective(name, deps) { .whenGET(url) .respond(template); }])); + }, + _ensureCompiled: function() { + if (typeof this.$element === 'undefined') { + throw "Can only call withController after registerPostHooks on directive test"; + } } }; } diff --git a/awx/ui/tests/unit/multi-select-list/multi-select-list.controller-test.js b/awx/ui/tests/unit/multi-select-list/multi-select-list.controller-test.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/awx/ui/tests/unit/multi-select-list/multi-select-list.directive-test.js b/awx/ui/tests/unit/multi-select-list/multi-select-list.directive-test.js new file mode 100644 index 0000000000..ed4f872c2b --- /dev/null +++ b/awx/ui/tests/unit/multi-select-list/multi-select-list.directive-test.js @@ -0,0 +1,197 @@ +import {describeModule} from 'tests/unit/describe-module'; +import mod from 'tower/shared/multi-select-list/main'; + +describeModule(mod.name) + .testDirective('multiSelectList', function(test) { + + var $scope; + var controller; + + test.use('
'); + + test.afterCompile(function(outerScope, scope) { + $scope = scope; + }); + + test.withController(function(_controller) { + controller = _controller; + }); + + it('works as an attribute on elements', function() { + inject(['$compile', function($compile) { + var node = $compile('
')($scope); + var classes = Array.prototype.slice.apply(node[0].classList) + expect(classes).to.contain('ng-scope'); + }]); + }); + + context('controller init', function() { + + it('initializes items and selection', function() { + expect($scope.items).to.be.empty; + expect($scope.selection.selectedItems).to.be.empty; + expect($scope.selection.deselectedItems).to.be.empty; + expect($scope.selection.isExtended).to.be.false; + }); + + it('wraps items when they are registered', function() { + var item = { name: 'blah' }; + var wrapped = controller.registerItem(item); + + expect(wrapped.hasOwnProperty('isSelected')).to.be.true; + expect(wrapped.hasOwnProperty('value')).to.be.true; + + expect(wrapped.isSelected).to.be.false; + expect(wrapped.value).to.eql(item); + + }); + + }); + + context('single select/deselect', function() { + + it('marks item as selected/not selected', function() { + var item = controller.registerItem({ name: 'blah' }); + controller.selectItem(item); + + expect(item.isSelected).to.be.true; + + controller.deselectItem(item); + expect(item.isSelected).to.be.false; + }); + + context('selectionChanged event', function() { + + it('triggers on select/deselect', function() { + var item = controller.registerItem({ name: 'blah' }); + var spy = sinon.spy(); + + $scope.$on('multiSelectList.selectionChanged', spy); + + controller.selectItem(item); + controller.deselectItem(item); + + expect(spy).to.have.been.calledTwice; + }); + + it('is called with the current selection', function() { + var item = controller.registerItem({ name: 'blah' }); + var spy = sinon.spy(); + + $scope.$on('multiSelectList.selectionChanged', spy); + + controller.selectItem(item); + + expect(spy).to.have.been.calledWith(sinon.match.object, + { selectedItems: + [ item.value + ], + deselectedItems: [], + isExtended: false + }); + }); + + it('is called with deselections', function() { + var item = controller.registerItem({ name: 'blah' }); + controller.selectItem(item); + + var spy = sinon.spy(); + + + $scope.$on('multiSelectList.selectionChanged', spy); + controller.deselectItem(item); + + expect(spy).to.have.been.calledWith(sinon.match.object, + { selectedItems: [], + deselectedItems: + [ item.value + ], + isExtended: false + }); + }); + + }); + + }); + + context('select/deselect all items', function() { + + it('marks all items as selected/deselected', function() { + var item1 = controller.registerItem({ name: 'blah' }); + var item2 = controller.registerItem({ name: 'diddy' }); + var item3 = controller.registerItem({ name: 'doo' }); + + controller.selectAll(); + + expect([item1, item2, item3]).to.all.have.property('isSelected', true); + + controller.deselectAll(); + + expect([item1, item2, item3]).to.all.have.property('isSelected', false); + }); + + context('selectionChanged event', function() { + + it('triggers with selections set to all the items', function() { + var item1 = controller.registerItem({ name: 'blah' }); + var item2 = controller.registerItem({ name: 'diddy' }); + var item3 = controller.registerItem({ name: 'doo' }); + var spy = sinon.spy(); + + $scope.$on('multiSelectList.selectionChanged', spy); + + controller.selectAll(); + + expect(spy).to.have.been.calledWith( + sinon.match.object, + { selectedItems: _.pluck([item1, item2, item3], "value"), + deselectedItems: [], + isExtended: false + }); + + controller.deselectAll(); + + expect(spy).to.have.been.calledWith( + sinon.match.object, + { selectedItems: [], + deselectedItems: _.pluck([item1, item2, item3], "value"), + isExtended: false + }); + + }); + + }); + + + it('tracks extended selection state', function() { + var spy = sinon.spy(); + var item1 = controller.registerItem({ name: 'blah' }); + var item2 = controller.registerItem({ name: 'diddy' }); + var item3 = controller.registerItem({ name: 'doo' }); + var allItems = _.pluck([item1, item2, item3], 'value'); + + controller.selectAll(); + controller.selectAllExtended(); + + expect($scope.selection).to.have.property('isExtended', true); + + controller.deselectAllExtended(); + + expect($scope.selection).to.have.property('isExtended', false); + expect($scope.selection) + .to.have.property('selectedItems') + .that.is.an('array') + .deep.equals(allItems); + }); + + + it('toggles extended state on deselectAll', function() { + controller.selectAllExtended(); + + controller.deselectAll(); + + expect($scope.selection).to.have.property('isExtended', false); + }); + }); + }); + diff --git a/awx/ui/tests/unit/multi-select-list/select-all.directive-test.js b/awx/ui/tests/unit/multi-select-list/select-all.directive-test.js new file mode 100644 index 0000000000..31f06c25e3 --- /dev/null +++ b/awx/ui/tests/unit/multi-select-list/select-all.directive-test.js @@ -0,0 +1,94 @@ +import {describeModule} from 'tests/unit/describe-module'; + +var mockController = { + selectAll: sinon.spy(), + deselectAll: sinon.spy(), + selectAllExtended: sinon.spy(), + deselectAllExtended: sinon.spy() +}; + +describeModule('multiSelectList') + .testDirective('selectAll', function(directive) { + + var $scope; + + directive.use(''); + + beforeEach(function() { + directive.element.data('$multiSelectListController', mockController); + }); + + afterEach(function() { + mockController.selectAll.reset(); + mockController.deselectAll.reset(); + mockController.selectAllExtended.reset(); + mockController.deselectAllExtended.reset(); + }); + + directive.afterCompile(function() { + + // Since we had to wrap select-all in a fake directive + // to mock the controller, we have to reach down to + // get it's isolate scope + // + $scope = + directive.$element.find('select-all').isolateScope(); + }); + + it('works as an element tag', function() { + var classes = Array.prototype.slice.apply(directive.$element[0].classList); + expect(classes).to.contain('ng-scope'); + }); + + it('calls select all when isSelected is true', function() { + $scope.isSelected = true; + $scope.doSelectAll(); + expect(mockController.selectAll).to.have.been.calledOnce; + }); + + it('calls deselect all when isSelected is false', function() { + $scope.isSelected = false; + $scope.doSelectAll(); + + expect(mockController.deselectAll).to.have.been.calledOnce; + }); + + it('calls deselect all extended when deselecting all', function() { + $scope.isSelected = false; + $scope.isSelectionExtended = true; + $scope.doSelectAll(); + + expect(mockController.deselectAllExtended).to.have.been.calledOnce; + }); + + context('input parameters', function() { + + var $outerScope; + + // We need to grab the parent scope object so we can control + // the parameters that are passed into the directive in the + // `use` call above + directive.withScope(function(_outerScope) { + $outerScope = _outerScope; + }); + + it('when true sets isSelected to false', function() { + + $scope.isSelected = true; + $outerScope.isEmpty = true; + $outerScope.$apply(); + + expect($scope).to.have.property('isSelected', false); + }); + + it('sets supportsExtendedItems when extendedItemsLength is given', function() { + $scope.supportsExtendedItems = false; + $outerScope.numItems = 5; + $outerScope.$apply(); + + expect($scope).to.have.property('supportsExtendedItems', true); + }); + + + }); + });