├── .gitignore ├── dist ├── ivh-multi-select.min.css ├── ivh-multi-select.css ├── ivh-multi-select.min.js └── ivh-multi-select.js ├── src ├── views │ ├── ivh-multi-select-no-results.html │ ├── ivh-multi-select-filter.html │ ├── ivh-multi-select-tools.html │ ├── ivh-multi-select-async.html │ └── ivh-multi-select.html ├── styles │ └── main.less └── scripts │ ├── module.js │ ├── directives │ ├── ivh-multi-select-filter.js │ ├── ivh-multi-select-tools.js │ ├── ivh-multi-select-no-results.js │ ├── ivh-multi-select-stay-open.js │ ├── ivh-multi-select-collapsable.js │ ├── ivh-multi-select.js │ └── ivh-multi-select-async.js │ ├── filters │ ├── ivh-multi-select-paginate.js │ ├── ivh-multi-select-label-filter.js │ └── ivh-multi-select-collect.js │ └── services │ ├── ivh-multi-select-selm.js │ └── ivh-multi-select-core.js ├── CONTRIBUTING.md ├── .travis.yml ├── .jshintrc ├── .editorconfig ├── test ├── spec │ ├── .jshintrc │ ├── filters │ │ └── ivh-multi-select-collect.js │ └── directives │ │ ├── ivh-multi-select.js │ │ └── ivh-multi-select-async.js └── helpers │ └── count-watchers.js ├── bower.json ├── package.json ├── LICENSE-MIT ├── .jscsrc ├── gruntfile.js └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | bower_components 3 | npm-debug.log 4 | test.html 5 | -------------------------------------------------------------------------------- /dist/ivh-multi-select.min.css: -------------------------------------------------------------------------------- 1 | .ivh-multi-select .ms-tools:hover{background-color:#fff}.ivh-multi-select .ms-item{cursor:pointer} -------------------------------------------------------------------------------- /src/views/ivh-multi-select-no-results.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Nothing to show 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /dist/ivh-multi-select.css: -------------------------------------------------------------------------------- 1 | .ivh-multi-select .ms-tools:hover { 2 | background-color: white; 3 | } 4 | .ivh-multi-select .ms-item { 5 | cursor: pointer; 6 | } 7 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | 2 | # Contributing 3 | 4 | Please take a look at our [contribution 5 | guidelines](https://github.com/iVantage/Contribution-Guidelines) before 6 | contributing. 7 | -------------------------------------------------------------------------------- /src/styles/main.less: -------------------------------------------------------------------------------- 1 | 2 | .ivh-multi-select { 3 | .ms-tools:hover { 4 | background-color: white; 5 | } 6 | 7 | .ms-item { 8 | cursor: pointer; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | node_js: 4 | - "4.1" 5 | 6 | before_script: 7 | - npm install -g bower 8 | - npm install -g grunt-cli 9 | - bower install 10 | 11 | script: 12 | - grunt 13 | -------------------------------------------------------------------------------- /src/scripts/module.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Main module declaration for ivh.multiSelect 4 | * 5 | * @package ivh.multiSelect 6 | * @copyright 2015 iVantage Health Analytics, Inc. 7 | */ 8 | 9 | angular.module('ivh.multiSelect', [ 10 | 'selectionModel' 11 | ]); 12 | -------------------------------------------------------------------------------- /src/views/ivh-multi-select-filter.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "predef": ["angular"], 3 | "browser": true, 4 | "bitwise": true, 5 | "camelcase": false, 6 | "curly": true, 7 | "eqeqeq": true, 8 | "immed": true, 9 | "latedef": true, 10 | "newcap": true, 11 | "noarg": true, 12 | "quotmark": "single", 13 | "regexp": true, 14 | "undef": true, 15 | "unused": false, 16 | "strict": true, 17 | "trailing": true, 18 | "smarttabs": false, 19 | "laxcomma": true, 20 | "onevar": false 21 | } 22 | -------------------------------------------------------------------------------- /src/scripts/directives/ivh-multi-select-filter.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * MS Filter 4 | * 5 | * Consolidated to share between sync and async multiselect 6 | * 7 | * @package ivh.multiSelect 8 | * @copyright 2015 iVantage Health Analytics, Inc. 9 | */ 10 | 11 | angular.module('ivh.multiSelect') 12 | .directive('ivhMultiSelectFilter', function() { 13 | 'use strict'; 14 | return { 15 | restrict: 'A', 16 | templateUrl: 'src/views/ivh-multi-select-filter.html' 17 | }; 18 | }); 19 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | 8 | [*] 9 | 10 | # Change these settings to your own preference 11 | indent_style = space 12 | indent_size = 2 13 | 14 | # We recommend you to keep these unchanged 15 | end_of_line = lf 16 | charset = utf-8 17 | trim_trailing_whitespace = true 18 | insert_final_newline = true 19 | 20 | [*.md] 21 | trim_trailing_whitespace = false 22 | -------------------------------------------------------------------------------- /src/scripts/directives/ivh-multi-select-tools.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * MS Tools (e.g. "Select All" button) 4 | * 5 | * Consolidated to share between sync and async multiselect 6 | * 7 | * @package ivh.multiSelect 8 | * @copyright 2015 iVantage Health Analytics, Inc. 9 | */ 10 | 11 | angular.module('ivh.multiSelect') 12 | .directive('ivhMultiSelectTools', function() { 13 | 'use strict'; 14 | return { 15 | restrict: 'A', 16 | templateUrl: 'src/views/ivh-multi-select-tools.html' 17 | }; 18 | }); 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/scripts/directives/ivh-multi-select-no-results.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Displays a "no results" message 4 | * 5 | * Consolidated to share between sync and async multiselect 6 | * 7 | * @package ivh.multiSelect 8 | * @copyright 2015 iVantage Health Analytics, Inc. 9 | */ 10 | 11 | angular.module('ivh.multiSelect') 12 | .directive('ivhMultiSelectNoResults', function() { 13 | 'use strict'; 14 | return { 15 | restrict: 'A', 16 | templateUrl: 'src/views/ivh-multi-select-no-results.html' 17 | }; 18 | }); 19 | 20 | -------------------------------------------------------------------------------- /test/spec/.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "predef": ["jasmine", "angular", "module", "inject", "describe", "beforeEach", "afterEach", "it", "expect", "jQuery", "countWacthers"], 3 | "browser": true, 4 | "bitwise": true, 5 | "camelcase": false, 6 | "curly": true, 7 | "eqeqeq": true, 8 | "immed": true, 9 | "latedef": true, 10 | "newcap": true, 11 | "noarg": true, 12 | "quotmark": "single", 13 | "regexp": true, 14 | "undef": true, 15 | "unused": false, 16 | "strict": true, 17 | "trailing": true, 18 | "smarttabs": false, 19 | "laxcomma": true, 20 | "onevar": false 21 | } 22 | 23 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angular-ivh-multi-select", 3 | "version": "0.8.1", 4 | "homepage": "https://github.com/iVantage/angular-ivh-multi-select", 5 | "license": "MIT", 6 | "main": [ 7 | "dist/ivh-multi-select.js", 8 | "dist/ivh-multi-select.css" 9 | ], 10 | "ignore": [ 11 | "**/.*", 12 | "node_modules", 13 | "bower_components", 14 | "test" 15 | ], 16 | "dependencies": { 17 | "angular": "^1.2", 18 | "selection-model": "angular-selection-model#^0.10.2" 19 | }, 20 | "devDependencies": { 21 | "jquery": "~2.1.4", 22 | "angular-mocks": "^1.2", 23 | "angular-ivh-pager": "~0.3.0" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/scripts/filters/ivh-multi-select-paginate.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Wrapper for ivhPaginateFilter if present 4 | * 5 | * @package ivh.multiSelect 6 | * @copyright 2015 iVantage Health Analytics, Inc. 7 | */ 8 | 9 | angular.module('ivh.multiSelect') 10 | .filter('ivhMultiSelectPaginate', ['$injector', function($injector) { 11 | 'use strict'; 12 | 13 | // Fall back to the identity function 14 | var filterFn = function(col) { 15 | return col; 16 | }; 17 | 18 | // Use ivhPaginateFilter if we have access to it 19 | if($injector.has('ivhPaginateFilter')) { 20 | filterFn = $injector.get('ivhPaginateFilter'); 21 | } 22 | 23 | return filterFn; 24 | }]); 25 | -------------------------------------------------------------------------------- /src/views/ivh-multi-select-tools.html: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 15 | 16 | 21 | 22 | -------------------------------------------------------------------------------- /src/scripts/directives/ivh-multi-select-stay-open.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Don't close the multiselect on click 4 | * 5 | * @package ivh.multiSelect 6 | * @copyright 2015 iVantage Health Analytics, Inc. 7 | */ 8 | 9 | angular.module('ivh.multiSelect') 10 | .directive('ivhMultiSelectStayOpen', function() { 11 | 'use strict'; 12 | return { 13 | restrict: 'A', 14 | link: function(scope, element, attrs) { 15 | 16 | /** 17 | * Clicks on this element should not cause the multi-select to close 18 | */ 19 | element.on('click', function($event) { 20 | var evt = $event.originalEvent || $event; 21 | evt.ivhMultiSelectIgnore = true; 22 | }); 23 | } 24 | }; 25 | }); 26 | 27 | -------------------------------------------------------------------------------- /src/scripts/filters/ivh-multi-select-label-filter.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * For filtering items by calculated labels 4 | * 5 | * @package ivh.multiSelect 6 | * @copyright 2016 iVantage Health Analytics, Inc. 7 | */ 8 | 9 | angular.module('ivh.multiSelect') 10 | .filter('ivhMultiSelectLabelFilter', [function(selectionModelOptions) { 11 | 'use strict'; 12 | 13 | return function(items, ctrl) { 14 | var str = ctrl.filterString; 15 | 16 | if(!items || !str) { 17 | return items; 18 | } 19 | 20 | var filtered = []; 21 | 22 | angular.forEach(items, function(item) { 23 | if(ctrl.getLabelFor(item).indexOf(str) > -1) { 24 | filtered.push(item); 25 | } 26 | }); 27 | 28 | return filtered; 29 | }; 30 | }]); 31 | 32 | 33 | -------------------------------------------------------------------------------- /test/spec/filters/ivh-multi-select-collect.js: -------------------------------------------------------------------------------- 1 | 2 | describe('Filter: ivhMultiSelectCollect', function() { 3 | 'use strict'; 4 | 5 | beforeEach(module('ivh.multiSelect')); 6 | 7 | var collect; 8 | 9 | beforeEach(inject(function(ivhMultiSelectCollectFilter) { 10 | collect = ivhMultiSelectCollectFilter; 11 | })); 12 | 13 | it('should gather collect selected item ids', function() { 14 | var list = [] 15 | , item = {selected: true, id: 'foo'}; 16 | collect(list, item); 17 | expect(list.length).toBe(1); 18 | expect(list[0]).toBe('foo'); 19 | }); 20 | 21 | it('should remove deselected item ids', function() { 22 | var list = ['foo', 'bar'] 23 | , item = {selected: false, id: 'foo'}; 24 | collect(list, item); 25 | expect(list.length).toBe(1); 26 | expect(list[0]).toBe('bar'); 27 | }); 28 | }); 29 | 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angular-ivh-multi-select", 3 | "version": "0.8.1", 4 | "scripts": { 5 | "test": "grunt test" 6 | }, 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/iVantage/angular-ivh-multi-select.git" 10 | }, 11 | "author": "iVantage", 12 | "bugs": { 13 | "url": "https://github.com/iVantage/angular-ivh-multi-select/issues" 14 | }, 15 | "homepage": "https://github.com/iVantage/angular-ivh-multi-select", 16 | "license": "MIT", 17 | "dependencies": {}, 18 | "devDependencies": { 19 | "grunt": "^0.4.5", 20 | "grunt-bump": "^0.3.1", 21 | "grunt-contrib-clean": "^0.6.0", 22 | "grunt-contrib-concat": "^0.5.1", 23 | "grunt-contrib-cssmin": "^0.12.2", 24 | "grunt-contrib-jasmine": "^0.8.2", 25 | "grunt-contrib-jshint": "^0.11.2", 26 | "grunt-contrib-less": "^1.0.1", 27 | "grunt-contrib-uglify": "^0.9.1", 28 | "grunt-contrib-watch": "^0.6.1", 29 | "grunt-jscs": "^1.8.0", 30 | "matchdep": "^0.3.0" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | Copyright (C) 2015 iVantage Health Analytics, Inc. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the "Software"), 5 | to deal in the Software without restriction, including without limitation 6 | the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | and/or sell copies of the Software, and to permit persons to whom the 8 | Software is furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included 11 | in all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 14 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 15 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 16 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, 17 | DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 18 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE 19 | OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 20 | 21 | 22 | -------------------------------------------------------------------------------- /test/helpers/count-watchers.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Count the number of watchers under a given element 4 | * 5 | * @see http://stackoverflow.com/questions/18499909/how-to-count-total-number-of-watches-on-a-page 6 | * @param {Angular.Element} $el The element to count watchers for 7 | * @return {Integer} The watcher count 8 | */ 9 | var countWacthers = function ($el) { 10 | var watchers = []; 11 | 12 | var f = function (element) { 13 | angular.forEach(['$scope', '$isolateScope'], function (scopeProperty) { 14 | if (element.data() && element.data().hasOwnProperty(scopeProperty)) { 15 | angular.forEach(element.data()[scopeProperty].$$watchers, function (watcher) { 16 | watchers.push(watcher); 17 | }); 18 | } 19 | }); 20 | 21 | angular.forEach(element.children(), function (childElement) { 22 | f(angular.element(childElement)); 23 | }); 24 | }; 25 | 26 | f($el); 27 | 28 | // Remove duplicate watchers 29 | var watchersWithoutDuplicates = []; 30 | angular.forEach(watchers, function(item) { 31 | if(watchersWithoutDuplicates.indexOf(item) < 0) { 32 | watchersWithoutDuplicates.push(item); 33 | } 34 | }); 35 | 36 | return watchersWithoutDuplicates.length; 37 | } 38 | -------------------------------------------------------------------------------- /src/scripts/filters/ivh-multi-select-collect.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * For when you really need to track ids of selected items 4 | * 5 | * A bit of a hack, meant to be used in conjunction with 6 | * `selection-model-on-change`: 7 | * 8 | * ``` 9 | *
12 | *
13 | * ``` 14 | * 15 | * @package ivh.multiSelect 16 | * @copyright 2015 iVantage Health Analytics, Inc. 17 | */ 18 | 19 | angular.module('ivh.multiSelect') 20 | .filter('ivhMultiSelectCollect', ['selectionModelOptions', function(selectionModelOptions) { 21 | 'use strict'; 22 | 23 | var defaultSelAttr = selectionModelOptions.get().selectedAttribute; 24 | 25 | return function(idsList, item, idAttr, selAttr) { 26 | if(!idsList || !item) { 27 | return idsList; 28 | } 29 | 30 | var isSelected = item[selAttr || defaultSelAttr] 31 | , itemId = item[idAttr || 'id'] 32 | , ixId = idsList.indexOf(itemId); 33 | 34 | if(isSelected && -1 === ixId) { 35 | idsList.push(itemId); 36 | } else if(!isSelected && ixId > -1) { 37 | idsList.splice(ixId, 1); 38 | } 39 | 40 | return idsList; 41 | }; 42 | }]); 43 | 44 | -------------------------------------------------------------------------------- /src/scripts/directives/ivh-multi-select-collapsable.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Listen for clicks outside the multi-select and collapse it if needed 4 | * 5 | * @package ivh.multiSelect 6 | * @copyright 2015 iVantage Health Analytics, Inc. 7 | */ 8 | 9 | angular.module('ivh.multiSelect') 10 | .directive('ivhMultiSelectCollapsable', ['$document', function($document) { 11 | 'use strict'; 12 | return { 13 | restrict: 'A', 14 | require: ['?^ivhMultiSelect', '?^ivhMultiSelectAsync'], 15 | link: function(scope, element, attrs, ctrls) { 16 | 17 | /** 18 | * Clicks on the body should close this multiselect 19 | * 20 | * ... unless the element has been tagged with 21 | * ivh-multi-select-stay-open... ;) 22 | * 23 | * Be a good doobee and clean up this click handler when our scope is 24 | * destroyed 25 | */ 26 | var $bod = $document.find('body'); 27 | 28 | var collapseMe = function($event) { 29 | var evt = $event.originalEvent || $event; 30 | if(!evt.ivhMultiSelectIgnore) { 31 | // Only one of the required parent controllers will be defined 32 | angular.forEach(ctrls, function(ms) { 33 | if(ms) { ms.isOpen = false; } 34 | }); 35 | scope.$digest(); 36 | } 37 | }; 38 | 39 | $bod.on('click', collapseMe); 40 | 41 | scope.$on('$destroy', function() { 42 | $bod.off('click', collapseMe); 43 | }); 44 | } 45 | }; 46 | }]); 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /src/views/ivh-multi-select-async.html: -------------------------------------------------------------------------------- 1 | 2 | 48 | 49 | -------------------------------------------------------------------------------- /.jscsrc: -------------------------------------------------------------------------------- 1 | { 2 | "requireCurlyBraces": [ 3 | "if", 4 | "else", 5 | "for", 6 | "while", 7 | "do", 8 | "try", 9 | "catch" 10 | ], 11 | "requireOperatorBeforeLineBreak": [ 12 | "?", 13 | "=", 14 | "+", 15 | "-", 16 | "/", 17 | "*", 18 | "==", 19 | "===", 20 | "!=", 21 | "!==", 22 | ">", 23 | ">=", 24 | "<", 25 | "<=" 26 | ], 27 | "requireCamelCaseOrUpperCaseIdentifiers": true, 28 | "validateQuoteMarks": "'", 29 | "disallowMultipleLineStrings": true, 30 | "disallowMixedSpacesAndTabs": true, 31 | "disallowTrailingWhitespace": true, 32 | "disallowSpaceAfterPrefixUnaryOperators": true, 33 | "disallowKeywordsOnNewLine": [ 34 | "else" 35 | ], 36 | "requireSpaceAfterKeywords": [], 37 | "requireSpaceBeforeBinaryOperators": [ 38 | "=", 39 | "+=", 40 | "-=", 41 | "*=", 42 | "/=", 43 | "%=", 44 | "<<=", 45 | ">>=", 46 | ">>>=", 47 | "&=", 48 | "|=", 49 | "^=", 50 | "+=", 51 | "+", 52 | "-", 53 | "*", 54 | "/", 55 | "%", 56 | "<<", 57 | ">>", 58 | ">>>", 59 | "&", 60 | "|", 61 | "^", 62 | "&&", 63 | "||", 64 | "===", 65 | "==", 66 | ">=", 67 | "<=", 68 | "<", 69 | ">", 70 | "!=", 71 | "!==" 72 | ], 73 | "requireSpaceAfterBinaryOperators": true, 74 | "requireSpacesInConditionalExpression": true, 75 | "requireSpaceBeforeBlockStatements": true, 76 | "requireSpaceBeforeObjectValues": true, 77 | "requireSpacesInForStatement": true, 78 | "requireLineFeedAtFileEnd": true, 79 | "requireSpacesInFunctionExpression": { 80 | "beforeOpeningCurlyBrace": true 81 | }, 82 | "requireSpacesInFunctionDeclaration": { 83 | "beforeOpeningCurlyBrace": true 84 | }, 85 | "disallowSpacesInsideArrayBrackets": "all", 86 | "disallowSpacesInsideParentheses": true, 87 | "disallowNewlineBeforeBlockStatements": true 88 | } 89 | 90 | -------------------------------------------------------------------------------- /src/views/ivh-multi-select.html: -------------------------------------------------------------------------------- 1 | 2 | 48 | 49 | -------------------------------------------------------------------------------- /src/scripts/services/ivh-multi-select-selm.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Selection Model helpers for Multi Select 4 | * 5 | * @package ivh.multiSelect 6 | * @copyright 2015 iVantage Health Analytics, Inc. 7 | */ 8 | 9 | angular.module('ivh.multiSelect') 10 | .factory('ivhMultiSelectSelm', ['selectionModelOptions', function(selectionModelOptions) { 11 | 'use strict'; 12 | var exports = {}; 13 | 14 | /** 15 | * We're overriding selection model defaults with our own 16 | * 17 | * May still be set by the user at the attribute level 18 | */ 19 | var selmOverrides = { 20 | type: 'checkbox', 21 | mode: 'multi-additive' 22 | }; 23 | 24 | /** 25 | * Returns the supported selection model properties 26 | * 27 | * Note that we're only interested in properties that may need to be watched 28 | * (i.e. `selection-model-on-change` is omitted) 29 | * 30 | * @return {Array} The list of props, look for {1} $scope and {0} on selection model props 31 | */ 32 | exports.propsMap = function() { 33 | return [ 34 | ['type', 'selectionModelType'], 35 | ['mode', 'selectionModelMode'], 36 | ['selectedAttribute', 'selectionModelSelectedAttribute'], 37 | ['selectedClass', 'selectionModelSelectedClass'], 38 | ['cleanupStategy', 'selectionModelCleanupStrategy'], 39 | ['selectedItems', 'selectionModelSelectedItems'] 40 | ]; 41 | }; 42 | 43 | /** 44 | * Merges and returns selection model defaults with overrides on the passed 45 | * scope. 46 | * 47 | * Accounts for IVH Multi Select selection model defaults 48 | * 49 | * @param {Scope} $scope Should have props matching supported selection model attrs 50 | * @return {Opbject} A hash of the merged options 51 | */ 52 | exports.options = function($scope) { 53 | var opts = angular.extend({}, selectionModelOptions.get(), selmOverrides); 54 | angular.forEach(exports.propsMap(), function(p) { 55 | if($scope[p[1]]) { 56 | opts[p[0]] = $scope[p[1]]; 57 | } 58 | }); 59 | return opts; 60 | }; 61 | 62 | return exports; 63 | }]); 64 | -------------------------------------------------------------------------------- /src/scripts/directives/ivh-multi-select.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * The Multi Select directive 4 | * 5 | * @package ivh.multiSelect 6 | * @copyright 2015 iVantage Health Analytics, Inc. 7 | */ 8 | 9 | angular.module('ivh.multiSelect') 10 | .directive('ivhMultiSelect', function() { 11 | 'use strict'; 12 | return { 13 | scope: { 14 | labelAttr: '=ivhMultiSelectLabelAttribute', 15 | labelExpr: '=ivhMultiSelectLabelExpression', 16 | 17 | /** 18 | * The universe of items 19 | */ 20 | items: '=ivhMultiSelectItems', 21 | 22 | /** 23 | * Options for selection model 24 | */ 25 | selectionModelType: '=', 26 | selectionModelMode: '=', 27 | selectionModelSelectedAttribute: '=', 28 | selectionModelSelectedClass: '=', 29 | selectionModelCleanupStrategy: '=', 30 | selectionModelSelectedItems: '=', 31 | 32 | /** 33 | * Should be an angular expression in which `item` is the collection 34 | * item that has changed selected state 35 | */ 36 | selOnChange: '&selectionModelOnChange' 37 | }, 38 | restrict: 'AE', 39 | templateUrl: 'src/views/ivh-multi-select.html', 40 | transclude: true, 41 | controllerAs: 'ms', 42 | controller: ['$document', '$scope', 'ivhMultiSelectCore', 43 | function($document, $scope, ivhMultiSelectCore) { 44 | 45 | /** 46 | * Mixin core functionality 47 | */ 48 | var ms = this; 49 | ivhMultiSelectCore.init(ms, $scope); 50 | 51 | /** 52 | * Attach the passed items to our controller for consistent interface 53 | * 54 | * Will be updated from the view as `$scope.items` changes 55 | */ 56 | ms.items = $scope.items; 57 | 58 | /** 59 | * Select all (or deselect) *not filtered out* items 60 | * 61 | * Note that if paging is enabled items on other pages will still be 62 | * selected as normal. 63 | */ 64 | ms.selectAllVisible = function(isSelected) { 65 | isSelected = angular.isDefined(isSelected) ? isSelected : true; 66 | var selectedAttr = ms.sel.selectedAttribute; 67 | angular.forEach(ms.items, function(item) { 68 | item[selectedAttr] = isSelected; 69 | ms.sel.onChange(item); 70 | }); 71 | }; 72 | }] 73 | }; 74 | }); 75 | -------------------------------------------------------------------------------- /src/scripts/services/ivh-multi-select-core.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Shared multi select controller functionality 4 | * 5 | * @package ivh.multiSelect 6 | * @copyright 2015 iVantage Health Analytics, Inc. 7 | */ 8 | 9 | angular.module('ivh.multiSelect') 10 | .factory('ivhMultiSelectCore', ['$injector', '$interpolate', 'ivhMultiSelectSelm', 11 | function($injector, $interpolate, ivhMultiSelectSelm) { 12 | 'use strict'; 13 | var exports = {}; 14 | 15 | /** 16 | * Adds shared functionality a multiselect controller 17 | */ 18 | exports.init = function(ms, $scope) { 19 | var pagerPageSize = 10 20 | , pagerUsePager = true; 21 | 22 | /** 23 | * Whether or not the dropdown is displayed 24 | * 25 | * See ivh-multi-select-collapsable 26 | * 27 | * Toggled whenever the user clicks the ol' button 28 | */ 29 | ms.isOpen = false; 30 | 31 | /** 32 | * The filter string entered by the user into our input control 33 | */ 34 | ms.filterString = ''; 35 | 36 | /** 37 | * We're embedding selection-model 38 | * 39 | * Forward supported `selection-model-*` attributes to the underlying 40 | * directive. 41 | */ 42 | ms.sel = ivhMultiSelectSelm.options($scope); 43 | 44 | /** 45 | * Disable the 'All'/'None' buttons when in single select mode 46 | */ 47 | ms.enableMultiSelect = 'single' !== ms.sel.mode; 48 | 49 | /** 50 | * Filter change hook, override as needed. 51 | * 52 | * Defined in core so as not to generate errors 53 | */ 54 | ms.onFilterChange = angular.noop; 55 | 56 | /** 57 | * Setup watchers for each selection model propety attached to us 58 | */ 59 | angular.forEach(ivhMultiSelectSelm.propsMap(), function(p) { 60 | var unwatch = $scope.$watch(p[1], function(newVal) { 61 | if(newVal) { 62 | ms.sel[p[0]] = newVal; 63 | if('mode' === p[0]) { 64 | ms.enableMultiSelect = 'single' !== newVal; 65 | } 66 | } 67 | }); 68 | $scope.$on('$destroy', unwatch); 69 | }); 70 | 71 | /** 72 | * Provide a way for the outside world to know about selection changes 73 | */ 74 | ms.sel.onChange = function(item) { 75 | $scope.selOnChange({item: item}); 76 | }; 77 | 78 | /** 79 | * The collection item attribute or expression to display as a label 80 | */ 81 | var labelAttr, labelFn; 82 | 83 | ms.getLabelFor = function(item) { 84 | return labelFn ? labelFn({item: item}) : item[labelAttr]; 85 | }; 86 | 87 | $scope.$watch('labelExpr || labelAttr', function() { 88 | labelAttr = $scope.labelAttr || 'label'; 89 | labelFn = $scope.labelExpr ? $interpolate($scope.labelExpr) : null; 90 | }); 91 | 92 | /** 93 | * We optionally suppor the ivh.pager module 94 | * 95 | * If it is present your items will be paged otherwise all are displayed 96 | */ 97 | ms.hasPager = pagerUsePager && $injector.has('ivhPaginateFilter'); 98 | ms.ixPage = 0; 99 | ms.sizePage = pagerPageSize; 100 | }; 101 | 102 | return exports; 103 | }]); 104 | 105 | -------------------------------------------------------------------------------- /gruntfile.js: -------------------------------------------------------------------------------- 1 | /*jshint node:true */ 2 | 3 | module.exports = function(grunt) { 4 | 'use strict'; 5 | 6 | // Project configuration 7 | grunt.initConfig({ 8 | pkg: grunt.file.readJSON('package.json'), 9 | 10 | jshint: { 11 | options: { 12 | jshintrc: '.jshintrc' 13 | }, 14 | gruntfile: 'gruntfile.js', 15 | src: 'src/**/*.js', 16 | spec: { 17 | options: { 18 | jshintrc: 'test/spec/.jshintrc' 19 | }, 20 | src: 'test/spec/**/*.js' 21 | } 22 | }, 23 | 24 | jscs: { 25 | options: { 26 | config: '.jscsrc' 27 | }, 28 | gruntfile: { 29 | files: { 30 | src: [ 31 | 'Gruntfile.js' 32 | ] 33 | } 34 | }, 35 | spec: { 36 | files: { 37 | src: [ 38 | 'test/spec/**/*.js' 39 | ] 40 | } 41 | }, 42 | scripts: { 43 | files: { 44 | src: [ 45 | 'src/scripts/**/*.js' 46 | ] 47 | } 48 | } 49 | }, 50 | 51 | clean: { 52 | dist: 'dist' 53 | }, 54 | 55 | concat: { 56 | options: {separator: '\n'}, 57 | dist: { 58 | src: ['src/scripts/module.js', 'src/scripts/**/*.js'], 59 | dest: 'dist/ivh-multi-select.js' 60 | } 61 | }, 62 | 63 | uglify: { 64 | dist: { 65 | src: 'dist/ivh-multi-select.js', 66 | dest: 'dist/ivh-multi-select.min.js' 67 | } 68 | }, 69 | 70 | less: { 71 | dist: { 72 | files: { 73 | 'dist/ivh-multi-select.css': 'src/styles/**/*.less' 74 | } 75 | } 76 | }, 77 | 78 | cssmin: { 79 | dist: { 80 | files: { 81 | 'dist/ivh-multi-select.min.css': 'dist/ivh-multi-select.css' 82 | } 83 | } 84 | }, 85 | 86 | jasmine: { 87 | spec: { 88 | // Load dist files to have external templates inlined 89 | src: ['dist/ivh-multi-select.js'], 90 | options: { 91 | specs: 'test/spec/**/*.js', 92 | summary: true, 93 | vendor: [ 94 | 'bower_components/jquery/dist/jquery.js', 95 | 'bower_components/angular/angular.js', 96 | 'bower_components/angular-mocks/angular-mocks.js', 97 | 'bower_components/angular-ivh-pager/dist/ivh-pager.js', 98 | 'bower_components/selection-model/dist/selection-model.js', 99 | 'test/helpers/count-watchers.js' 100 | ] 101 | } 102 | } 103 | }, 104 | 105 | watch: { 106 | scripts: { 107 | files: 'src/scripts/**/*.js', 108 | tasks: ['test'] // builds scripts 109 | }, 110 | styles: { 111 | files: 'src/styles/**/*.less', 112 | tasks: ['build:styles'] 113 | }, 114 | tests: { 115 | files: 'test/spec/**/*.js', 116 | tasks: ['testlte'] 117 | } 118 | }, 119 | 120 | bump: { 121 | options: { 122 | commitMessage: 'chore: Bump for release (v%VERSION%)', 123 | files: ['package.json', 'bower.json'], 124 | commitFiles: ['package.json', 'bower.json'], 125 | push: false 126 | } 127 | } 128 | }); 129 | 130 | grunt.registerTask('ng-inline', function() { 131 | // Look for "templateUrl"s in concat:dist and swap them out for "template" 132 | // with the template's contents. 133 | var s = grunt.file.read('dist/ivh-multi-select.js'); 134 | s = s.replace(/templateUrl: '([^']+)'/g, function(match, $1) { 135 | var c = grunt.file.read($1) 136 | .replace(/\s*\r?\n\s*/g, '\\n') 137 | .replace(/'/g, '\\\''); 138 | return 'template: \'' + c + '\''; 139 | }); 140 | grunt.file.write('dist/ivh-multi-select.js', s); 141 | }); 142 | 143 | // Load plugins 144 | require('matchdep').filterDev('grunt-*').forEach(grunt.loadNpmTasks); 145 | 146 | grunt.registerTask('test', [ 147 | 'build:scripts', 148 | 'jshint', 149 | 'jscs', 150 | 'build:scripts', 151 | 'jasmine' 152 | ]); 153 | 154 | grunt.registerTask('testlte', [ 155 | 'build:scripts', 156 | 'jshint', 157 | 'jscs', 158 | 'jasmine' 159 | ]); 160 | 161 | grunt.registerTask('build:scripts', [ 162 | 'concat', 163 | 'ng-inline', 164 | 'uglify' 165 | ]); 166 | 167 | grunt.registerTask('build:styles', [ 168 | 'less', 169 | 'cssmin' 170 | ]); 171 | 172 | grunt.registerTask('build', [ 173 | 'build:scripts', 174 | 'build:styles' 175 | ]); 176 | 177 | grunt.registerTask('default', [ 178 | 'clean', 179 | 'test', // builds scripts 180 | 'build:styles' 181 | ]); 182 | 183 | }; 184 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # ivh.multiSelect 3 | 4 | [![Build Status](https://secure.travis-ci.org/iVantage/angular-ivh-multi-select.png?branch=master)](https://travis-ci.org/iVantage/angular-ivh-multi-select) 5 | 6 | > An elegant and efficient multi select for AngularJS apps. 7 | 8 | IVH Multi Select aims to provide a robust multiselect component while keeping a 9 | careful eye on performance and minizing watch counts. While collapsed IVH Multi 10 | Select will create just ~10 watchers, and only ~40 while expanded. 11 | 12 | Note that IVH Multi Select assumes Bootstrap 3 styles. 13 | 14 | 15 | ## Installation 16 | 17 | Install with bower: 18 | 19 | ``` 20 | bower install --save angular-ivh-multi-select 21 | ``` 22 | 23 | *Note: You may optionally include angular-ivh-auto-focus as well to 24 | automatically focus multi select input controls. See the auto focus section 25 | below for detail.* 26 | 27 | 28 | ## Usage 29 | 30 | Add this module as a dependency to your app: 31 | 32 | ``` 33 | angular.module('myApp', ['ivh.multiSelect']); 34 | ``` 35 | 36 | At a minimum you must provide a collection of items to select from: 37 | 38 | ```html 39 |
41 | Choose some items! 42 |
43 | ``` 44 | 45 | IVH Multi Select will display a button that when clicked shows a menu of items 46 | to select complete with checkboxes and pagination if availabe. 47 | 48 | We're using the [selection-model][sm] component to manage selections internally. 49 | You can configure this directive's behavior by working with the 50 | [selectionModelOptionsProvider][sm-opt]. Inline `selection-model-*` attributes 51 | will also be forwarded to the underlying selection model: 52 | 53 | ```html 54 |
57 | Choose some items! 58 |
59 | ``` 60 | 61 | This includes `selection-model-on-change` with the notable exception that when 62 | this expression is evaluated IVH Multi Select will provide a scope variable, 63 | `item`, which will be a reference to the collection item whose selected status 64 | has changed. 65 | 66 | 67 | ### Labels 68 | 69 | Labels can be pulled from collection item properties or created with a custom 70 | expression: 71 | 72 | ```html 73 |
76 | Use "name" attribute for item labels 77 |
78 | 79 | 82 |
85 | Use an angular expression for item labels 86 |
87 | ``` 88 | 89 | 90 | ### Pagination 91 | 92 | In keeping with our focus on performance, IVH Multi Select will paginate your 93 | lists if you happen to have the [ivh.pager][pager] module included as a 94 | dependency (v0.3.0 or greater). 95 | 96 | This is not a hard dependency, if you do not include IVH Pager your lists will 97 | be displayed in full. 98 | 99 | 100 | ### Tracking Selected IDs 101 | 102 | We're using selection-model internally to manage selections. As a convenience we 103 | provide a filter, `ivhMultiSelectCollect`, to help convert these to arrays of 104 | IDs if that is more in keeping with your use case. Note that this behavior is 105 | only supported when using the `multi-additive` selection mode (the default). 106 | 107 | ``` 108 | 112 |
115 |
116 | 117 | 120 |
123 |
124 | ``` 125 | 126 | 127 | ### Server Side Paging 128 | 129 | For times when it would be too expensive to keep the entire list of available 130 | options on the client side we provide a second directive which allows you to 131 | provide an item getter function. 132 | 133 | ```html 134 |
138 | Blargus 139 |
140 | ``` 141 | 142 | Note the **async** in `ivh-multi-select-async`, this is in fact a separate 143 | directive from `ivh-multi-select` but supports a nearly identical set of 144 | attributes with the following exceptions: 145 | 146 | - The values assigned to `ivh-multi-select-fetcher` is required and expected to 147 | be a function (signiture detailed below). 148 | - The value assigned to `ivh-multi-select-id-attribute` will be used to compare 149 | newly fetched items with those in your selected items array as they are paged 150 | in and out. This is optional and defaults to `'id'`. 151 | - `ivh-multi-select-items` is ignored... getting our items from the server is 152 | kinda the point. 153 | - You may use the `ivh-multi-select-selected-items` attribute to provide an 154 | array of selected items. This will be kept up to date with user driven changes 155 | as items are selected and deselected. Note that only `'single'` and 156 | `'multi-additive'` (the default) selection modes are supported. Order and 157 | item reference preservation is not guarenteed. 158 | 159 | #### `ivh-multi-select-fetcher` 160 | 161 | The function we'll use to fetch pages of items. 162 | 163 | This should accept an options object with the following properties: 164 | 165 | - filter: A string, whatever the user has entered in the filter box 166 | - page: The zero-based page number we're requesting for paginated results. 167 | - pageSize: The number of items we expect per page 168 | 169 | 170 | The function should return an object, or promise which resolves to shuch an 171 | object, with the following properties: 172 | 173 | 174 | - items: A page of collection items, if more than one page was returned only 175 | the first `pageSize` will be displayed (assuming paging is enabled). 176 | - page: [Optional] The zero-based page number corresponding to the returned 177 | results. If ommitted and paging is enabled we will assume `page` from the 178 | request options. 179 | - pageSize: The size of a page of results, if omitted we will assume `pageSize` 180 | from the request options. 181 | - `totalCount`: The total (unpaged) result set count 182 | 183 | 184 | ### Autofocus Input fields 185 | 186 | Include the [IVH Auto Focus](https://github.com/iVantage/angular-ivh-auto-focus) 187 | module in your application to enable auto-focusing multi select inputs. 188 | 189 | Install with bower: 190 | 191 | ```shell 192 | bower install --save angular-ivh-auto-focus 193 | ``` 194 | 195 | Include in your application 196 | 197 | ```javascript 198 | angular.module('myApp', [ 199 | 'ivh.multiSelect', 200 | 'ivh.autoFocus' 201 | ]); 202 | ``` 203 | 204 | Enjoy auto focusing multi select inputs :smile:. 205 | 206 | ## Testing 207 | 208 | Use `npm test` to run the full suite of linting, style checks, and unit tests. 209 | 210 | Or, run each individually: 211 | 212 | - Use `grunt jshint` for linting 213 | - Use `grunt jscs` for coding style checks 214 | - Use `grunt jasmine` to unit tests 215 | 216 | For ease of development the `grunt watch` task will run each of the above as 217 | needed when you make changes to source files. 218 | 219 | 220 | ## Changelog 221 | 222 | - 2015-10-26 v0.8.0 (sync) Filter on calculated label 223 | - 2015-10-26 v0.6.0 Add support for server side paging 224 | - 2015-10-26 v0.5.0 Add custom label expressions 225 | - 2015-10-20 v0.2.0 Forward selection-model-on-change 226 | - 2015-10-08 v0.2.0 Forward options to selection model 227 | - 2015-10-07 v0.1.0 Initial release 228 | 229 | 230 | ## License 231 | 232 | MIT 233 | 234 | [sm]: https://github.com/jtrussell/angular-selection-model 235 | [sm-opt]: https://github.com/jtrussell/angular-selection-model#the-selectionmodeloptionsprovider 236 | [pager]: https://github.com/ivantage/angular-ivh-pager 237 | -------------------------------------------------------------------------------- /test/spec/directives/ivh-multi-select.js: -------------------------------------------------------------------------------- 1 | 2 | describe('Directive: ivhMultiSelect', function() { 3 | 'use strict'; 4 | 5 | beforeEach(module('ivh.multiSelect')); 6 | 7 | var ng = angular 8 | , $ = jQuery 9 | , scope 10 | , c; // compile 11 | 12 | beforeEach(inject(function($rootScope, $compile) { 13 | scope = $rootScope.$new(); 14 | 15 | c = function(tpl, s) { 16 | s = s || scope; 17 | tpl = ng.isArray(tpl) ? tpl.join('\n') : tpl; 18 | var $el = $compile(ng.element(tpl))(s); 19 | s.$apply(); 20 | return $el; 21 | }; 22 | })); 23 | 24 | it('should create a button with text as the transcluded content', function() { 25 | var $el = c([ 26 | '
', 28 | 'Blargus', 29 | '
' 30 | ]); 31 | 32 | var tText = $el.text().trim(); 33 | expect(tText).toBe('Blargus'); 34 | }); 35 | 36 | it('Its watcher count should be invariant in the number of list items while collapsed', function() { 37 | scope.items1 = [{label: 'foo'}]; 38 | scope.items2 = [{label: 'foo'}, {label: 'bar'}, {label: 'wowza'}]; 39 | 40 | var $el1 = c([ 41 | '
', 43 | 'Blargus', 44 | '
' 45 | ]); 46 | 47 | var $el2 = c([ 48 | '
', 50 | 'Blargus', 51 | '
' 52 | ]); 53 | 54 | var numWatchers1 = countWacthers($el1); 55 | var numWatchers2 = countWacthers($el2); 56 | 57 | expect(numWatchers1).toBe(numWatchers2); 58 | }); 59 | 60 | it('should display items when the button is clicked', function() { 61 | scope.items = [{label: 'foo'}]; 62 | 63 | var $el = c([ 64 | '
', 66 | 'Blargus', 67 | '
' 68 | ]); 69 | 70 | $el.find('button').click(); 71 | 72 | var liText = $el.find('li.ms-item').first().text().trim(); 73 | 74 | expect(liText).toBe('foo'); 75 | }); 76 | 77 | it('should add items to the menu when added later', function() { 78 | scope.items = [{label: 'foo'}]; 79 | 80 | var $el = c([ 81 | '
', 83 | 'Blargus', 84 | '
' 85 | ]); 86 | 87 | $el.find('button').click(); 88 | 89 | scope.items.push({label: 'bar'}); 90 | scope.$apply(); 91 | var numLis = $el.find('li.ms-item').length; 92 | 93 | expect(numLis).toBe(2); 94 | }); 95 | 96 | it('should update the list when it changes reference', function() { 97 | scope.items = [{label: 'foo'}]; 98 | 99 | var $el = c([ 100 | '
', 102 | 'Blargus', 103 | '
' 104 | ]); 105 | 106 | $el.find('button').click(); 107 | 108 | scope.items = [{label: 'a'}, {label: 'b'}, {label: 'c'}]; 109 | 110 | scope.$apply(); 111 | var numLis = $el.find('li.ms-item').length; 112 | 113 | expect(numLis).toBe(3); 114 | }); 115 | 116 | it('should allow a custom label expression', function() { 117 | scope.items = [{ 118 | name: 'Foo', 119 | num: 5 120 | }, { 121 | name: 'Bar', 122 | num: 9 123 | }]; 124 | 125 | var $el = c([ 126 | '
', 129 | 'Blargus', 130 | '
' 131 | ]); 132 | 133 | $el.find('button').click(); 134 | 135 | var t = $el.find('li.ms-item').first().text().trim(); 136 | expect(t).toBe('5: Foo'); 137 | }); 138 | 139 | it('should allow variable label expressions', function() { 140 | scope.items = [{ 141 | name: 'Foo', 142 | num: 5 143 | }, { 144 | name: 'Bar', 145 | num: 9 146 | }]; 147 | 148 | // But... it will change later ;) 149 | scope.labelExpr = '{{item.name}}'; 150 | 151 | var $el = c([ 152 | '
', 155 | 'Blargus', 156 | '
' 157 | ]); 158 | 159 | // Oh nos! It's different! 160 | scope.labelExpr = '{{item.num}}: {{item.name}}'; 161 | scope.$apply(); 162 | 163 | $el.find('button').click(); 164 | 165 | var t = $el.find('li.ms-item').first().text().trim(); 166 | expect(t).toBe('5: Foo'); 167 | }); 168 | 169 | it('should allow functions that return a label expression', function() { 170 | scope.items = [{ 171 | name: 'Foo', 172 | num: 5 173 | }, { 174 | name: 'Bar', 175 | num: 9 176 | }]; 177 | 178 | scope.getLabelExpr = function() { 179 | return '{{item.num}}: {{item.name}}'; 180 | }; 181 | 182 | var $el = c([ 183 | '
', 186 | 'Blargus', 187 | '
' 188 | ]); 189 | 190 | $el.find('button').click(); 191 | 192 | var t = $el.find('li.ms-item').first().text().trim(); 193 | expect(t).toBe('5: Foo'); 194 | }); 195 | 196 | it('should allow a variable which holds the list of selected items', function() { 197 | scope.items = [{label: 'foo'}]; 198 | scope.selectedItems = []; 199 | 200 | var $el = c([ 201 | '
', 204 | 'Blargus', 205 | '
' 206 | ]); 207 | 208 | $el.find('button').click(); 209 | $el.find('li.ms-item').first().click(); 210 | 211 | expect(scope.selectedItems.length).toBe(1); 212 | }); 213 | 214 | it('should show the all/none buttons when multi-select is enabled', function() { 215 | scope.items = [{label: 'foo'}]; 216 | 217 | var $el = c([ 218 | '
', 220 | 'Blargus', 221 | '
' 222 | ]); 223 | 224 | $el.find('button').click(); 225 | 226 | expect($el.find('button:contains("All")').length).toBe(1); 227 | expect($el.find('button:contains("None")').length).toBe(1); 228 | }); 229 | 230 | it('should not show the all/none buttons when multi-select is disabled', function() { 231 | scope.items = [{label: 'foo'}]; 232 | 233 | var $el = c([ 234 | '
', 237 | 'Blargus', 238 | '
' 239 | ]); 240 | 241 | $el.find('button').click(); 242 | 243 | expect($el.find('button:contains("All")').length).toBe(0); 244 | expect($el.find('button:contains("None")').length).toBe(0); 245 | }); 246 | 247 | describe('filtering', function() { 248 | var doFilter = function($el, str) { 249 | var $msFilter = $el.find('input[type=text]'); 250 | $msFilter.val(str); 251 | $msFilter.change(); 252 | inject(function($timeout) { 253 | $timeout.flush(); 254 | }); 255 | }; 256 | 257 | it('should filter by label attribute', function() { 258 | scope.items = [{label: 'foo'}, {label: 'bar'}]; 259 | 260 | var $el = c([ 261 | '
', 263 | 'Blargus', 264 | '
' 265 | ]); 266 | 267 | $el.find('button').click(); 268 | 269 | doFilter($el, 'foo'); 270 | 271 | var msItems = $el.find('li.ms-item'); 272 | expect(msItems.length).toBe(1); 273 | }); 274 | 275 | it('should filter by label expression', function() { 276 | scope.items = [{name: 'foo'}, {name: 'bar'}]; 277 | 278 | var $el = c([ 279 | '
', 282 | 'Blargus', 283 | '
' 284 | ]); 285 | 286 | $el.find('button').click(); 287 | 288 | doFilter($el, 'fooey'); 289 | 290 | var msItems = $el.find('li.ms-item'); 291 | expect(msItems.length).toBe(1); 292 | }); 293 | 294 | it('should not consider other attributes when filtering', function() { 295 | scope.items = [{label: 'foo', secret: 'wow'}, {label: 'bar'}]; 296 | 297 | var $el = c([ 298 | '
', 300 | 'Blargus', 301 | '
' 302 | ]); 303 | 304 | $el.find('button').click(); 305 | 306 | doFilter($el, 'wow'); 307 | 308 | var msItems = $el.find('li.ms-item'); 309 | expect(msItems.length).toBe(0); 310 | }); 311 | 312 | }); 313 | }); 314 | -------------------------------------------------------------------------------- /dist/ivh-multi-select.min.js: -------------------------------------------------------------------------------- 1 | angular.module("ivh.multiSelect",["selectionModel"]),angular.module("ivh.multiSelect").directive("ivhMultiSelectAsync",function(){"use strict";return{scope:{labelAttr:"=ivhMultiSelectLabelAttribute",labelExpr:"=ivhMultiSelectLabelExpression",idAttr:"=ivhMultiSelectIdAttribute",selectedItems:"=ivhMultiSelectSelectedItems",getItems:"=ivhMultiSelectFetcher",selectionModelType:"=",selectionModelMode:"=",selectionModelSelectedAttribute:"=",selectionModelSelectedClass:"=",selectionModelCleanupStrategy:"=",selOnChange:"&selectionModelOnChange"},restrict:"AE",template:'\n\n',transclude:!0,controllerAs:"ms",controller:["$document","$scope","$q","ivhMultiSelectCore",function(a,b,c,d){var e=this;d.init(e,b);var f=b.idAttr||"id",g=b.selectedItems||[],h=function(a){for(var b=g.length;b--;)if(g[b][f]===a[f])return!0;return!1},i=function(){for(var a=e.sel.selectedAttribute,b=e.items.length;b--;)e.items[b][a]=h(e.items[b])};e.onSelectionChange=function(a){var b,c=e.sel.selectedAttribute;if("single"===e.sel.mode)if(a[c])g.length=0,g.push(a);else for(b=g.length;b--;)a[f]===g[b][f]&&g.splice(b,1);else{for(b=g.length;b--;)if(g[b][f]===a[f]){g.splice(b,1);break}a[c]&&g.push(a)}},e.items=[],e.countItems=0,e.selectAllVisible=function(a){a=angular.isDefined(a)?a:!0;var d,h=e.sel.selectedAttribute;if(a===!1&&""===e.filterString){for(d=g.length;d--;)g[d][h]=!1,e.sel.onChange(g[d]);g.length=0}else{var i=Math.ceil(e.countItems/2);c.all([b.getItems({filter:e.filterString,page:0,pageSize:i}),b.getItems({filter:e.filterString,page:1,pageSize:i})]).then(function(b){var c=b[0].items.concat(b[1].items),i={},j={};for(d=c.length;d--;){var k=c[d][f];i.hasOwnProperty(k)&&c.splice(d,1),i[k]=1}for(d=g.length;d--;)i.hasOwnProperty(g[d][f])&&(j[g[d][f]]=1,g.splice(d,1));for(a&&Array.prototype.push.apply(g,c),d=e.items.length;d--;)e.items[d][h]=a,j.hasOwnProperty(e.items[d][f])||e.sel.onChange(e.items[d])})}},e.getItems=function(){if(!e.isOpen)return c.when(e.item);var a=++j;return c.when(b.getItems({filter:e.filterString,page:e.ixPage,pageSize:e.sizePage})).then(function(b){return a===j?(e.items=b.items,e.ixPage=b.page||e.ixPage,e.sizePage=b.pageSize||e.sizePage,e.countItems=b.totalCount||e.items.length,e.items.length>e.sizePage&&(e.items.length=e.sizePage),i(),e.items):void 0},function(a){return e.items=[],e.countItems=0,e.items})};var j=0;e.onFilterChange=function(){e.ixPage=0,e.getItems()},e.onPageChange=function(a,b){e.ixPage=a,e.getItems()},b.$watch("selectedItems",function(a,b){a&&a!==b&&(g=a,i())})}]}}),angular.module("ivh.multiSelect").directive("ivhMultiSelectCollapsable",["$document",function(a){"use strict";return{restrict:"A",require:["?^ivhMultiSelect","?^ivhMultiSelectAsync"],link:function(b,c,d,e){var f=a.find("body"),g=function(a){var c=a.originalEvent||a;c.ivhMultiSelectIgnore||(angular.forEach(e,function(a){a&&(a.isOpen=!1)}),b.$digest())};f.on("click",g),b.$on("$destroy",function(){f.off("click",g)})}}}]),angular.module("ivh.multiSelect").directive("ivhMultiSelectFilter",function(){"use strict";return{restrict:"A",template:'\n\n\n\n'}}),angular.module("ivh.multiSelect").directive("ivhMultiSelectNoResults",function(){"use strict";return{restrict:"A",template:'\n\nNothing to show\n\n\n'}}),angular.module("ivh.multiSelect").directive("ivhMultiSelectStayOpen",function(){"use strict";return{restrict:"A",link:function(a,b,c){b.on("click",function(a){var b=a.originalEvent||a;b.ivhMultiSelectIgnore=!0})}}}),angular.module("ivh.multiSelect").directive("ivhMultiSelectTools",function(){"use strict";return{restrict:"A",template:'\n\n\n\n\n'}}),angular.module("ivh.multiSelect").directive("ivhMultiSelect",function(){"use strict";return{scope:{labelAttr:"=ivhMultiSelectLabelAttribute",labelExpr:"=ivhMultiSelectLabelExpression",items:"=ivhMultiSelectItems",selectionModelType:"=",selectionModelMode:"=",selectionModelSelectedAttribute:"=",selectionModelSelectedClass:"=",selectionModelCleanupStrategy:"=",selectionModelSelectedItems:"=",selOnChange:"&selectionModelOnChange"},restrict:"AE",template:'\n\n',transclude:!0,controllerAs:"ms",controller:["$document","$scope","ivhMultiSelectCore",function(a,b,c){var d=this;c.init(d,b),d.items=b.items,d.selectAllVisible=function(a){a=angular.isDefined(a)?a:!0;var b=d.sel.selectedAttribute;angular.forEach(d.items,function(c){c[b]=a,d.sel.onChange(c)})}}]}}),angular.module("ivh.multiSelect").filter("ivhMultiSelectCollect",["selectionModelOptions",function(a){"use strict";var b=a.get().selectedAttribute;return function(a,c,d,e){if(!a||!c)return a;var f=c[e||b],g=c[d||"id"],h=a.indexOf(g);return f&&-1===h?a.push(g):!f&&h>-1&&a.splice(h,1),a}}]),angular.module("ivh.multiSelect").filter("ivhMultiSelectLabelFilter",[function(a){"use strict";return function(a,b){var c=b.filterString;if(!a||!c)return a;var d=[];return angular.forEach(a,function(a){b.getLabelFor(a).indexOf(c)>-1&&d.push(a)}),d}}]),angular.module("ivh.multiSelect").filter("ivhMultiSelectPaginate",["$injector",function(a){"use strict";var b=function(a){return a};return a.has("ivhPaginateFilter")&&(b=a.get("ivhPaginateFilter")),b}]),angular.module("ivh.multiSelect").factory("ivhMultiSelectCore",["$injector","$interpolate","ivhMultiSelectSelm",function(a,b,c){"use strict";var d={};return d.init=function(d,e){var f=10,g=!0;d.isOpen=!1,d.filterString="",d.sel=c.options(e),d.enableMultiSelect="single"!==d.sel.mode,d.onFilterChange=angular.noop,angular.forEach(c.propsMap(),function(a){var b=e.$watch(a[1],function(b){b&&(d.sel[a[0]]=b,"mode"===a[0]&&(d.enableMultiSelect="single"!==b))});e.$on("$destroy",b)}),d.sel.onChange=function(a){e.selOnChange({item:a})};var h,i;d.getLabelFor=function(a){return i?i({item:a}):a[h]},e.$watch("labelExpr || labelAttr",function(){h=e.labelAttr||"label",i=e.labelExpr?b(e.labelExpr):null}),d.hasPager=g&&a.has("ivhPaginateFilter"),d.ixPage=0,d.sizePage=f},d}]),angular.module("ivh.multiSelect").factory("ivhMultiSelectSelm",["selectionModelOptions",function(a){"use strict";var b={},c={type:"checkbox",mode:"multi-additive"};return b.propsMap=function(){return[["type","selectionModelType"],["mode","selectionModelMode"],["selectedAttribute","selectionModelSelectedAttribute"],["selectedClass","selectionModelSelectedClass"],["cleanupStategy","selectionModelCleanupStrategy"],["selectedItems","selectionModelSelectedItems"]]},b.options=function(d){var e=angular.extend({},a.get(),c);return angular.forEach(b.propsMap(),function(a){d[a[1]]&&(e[a[0]]=d[a[1]])}),e},b}]); -------------------------------------------------------------------------------- /src/scripts/directives/ivh-multi-select-async.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * The (Async) Multi Select directive 4 | * 5 | * @package ivh.multiSelect 6 | * @copyright 2015 iVantage Health Analytics, Inc. 7 | */ 8 | 9 | angular.module('ivh.multiSelect') 10 | .directive('ivhMultiSelectAsync', function() { 11 | 'use strict'; 12 | return { 13 | scope: { 14 | labelAttr: '=ivhMultiSelectLabelAttribute', 15 | labelExpr: '=ivhMultiSelectLabelExpression', 16 | 17 | /** 18 | * Used to compare freshly paged in collection items with those in the 19 | * selected items array 20 | */ 21 | idAttr: '=ivhMultiSelectIdAttribute', 22 | 23 | /** 24 | * A managed list of currently selected items 25 | */ 26 | selectedItems: '=ivhMultiSelectSelectedItems', 27 | 28 | /** 29 | * The function we'll use to fetch pages of items 30 | * 31 | * Should accept an options object with the following properties: 32 | * 33 | * - filter: A string, whatever the user has entered in the filte box 34 | * - page: The zero-based page number we're requesting for paginated 35 | * results. 36 | * - pageSize: The number of items we expect per page 37 | * 38 | * The function should return an object, or promise which resolves to 39 | * shuch an object, with the following properties: 40 | * 41 | * - items: A page of collection items, if more than one page was 42 | * returned only the first `pageSize` will be displayed (assuming 43 | * paging is enabled). 44 | * - page: [Optional] The zero-based page number corresponding to the 45 | * returned results. If ommitted and paging is enabled we will assume 46 | * `page` from the request options. 47 | * - pageSize: The size of a page of results, if omitted we will assume 48 | * `pageSize` from the request options. 49 | * - `totalCount`: The total (unpaged) result set count 50 | * 51 | */ 52 | getItems: '=ivhMultiSelectFetcher', 53 | 54 | /** 55 | * Options for selection model 56 | */ 57 | selectionModelType: '=', 58 | selectionModelMode: '=', 59 | selectionModelSelectedAttribute: '=', 60 | selectionModelSelectedClass: '=', 61 | selectionModelCleanupStrategy: '=', 62 | 63 | /** 64 | * Should be an angular expression in which `item` is the collection 65 | * item that has changed selected state 66 | */ 67 | selOnChange: '&selectionModelOnChange' 68 | }, 69 | restrict: 'AE', 70 | templateUrl: 'src/views/ivh-multi-select-async.html', 71 | transclude: true, 72 | controllerAs: 'ms', 73 | controller: ['$document', '$scope', '$q', 'ivhMultiSelectCore', 74 | function($document, $scope, $q, ivhMultiSelectCore) { 75 | 76 | /** 77 | * Mixin core functionality 78 | */ 79 | var ms = this; 80 | ivhMultiSelectCore.init(ms, $scope); 81 | 82 | /** 83 | * Async flavor supports only 'multi-additive' and 'single' selection 84 | * model modes. 85 | * 86 | * @todo blow up if we've been given another mode. 87 | */ 88 | 89 | /** 90 | * The async version of multi-select can't rely on a local collection 91 | * 92 | * Instead we work with an array of selected items. These will be matche 93 | * up with items fetched from th server by their ID attribute. 94 | * 95 | * As the user selects and deselected items those items will be added 96 | * and removed from the array of selected items. 97 | */ 98 | var idAttr = $scope.idAttr || 'id' 99 | , selectedItems = $scope.selectedItems || []; 100 | 101 | /** 102 | * If we are tracking a selection update the new page of things 103 | */ 104 | var itemIsSelected = function(item) { 105 | for(var ix = selectedItems.length; ix--;) { 106 | if(selectedItems[ix][idAttr] === item[idAttr]) { 107 | return true; 108 | } 109 | } 110 | return false; 111 | }; 112 | 113 | var updatePageSelection = function() { 114 | var selectedAttr = ms.sel.selectedAttribute; 115 | for(var ix = ms.items.length; ix--;) { 116 | ms.items[ix][selectedAttr] = itemIsSelected(ms.items[ix]); 117 | } 118 | }; 119 | 120 | /** 121 | * Update the selection as reported to the user whenever we have 122 | * selection model updates 123 | * 124 | * Note that only single and multi-additive selection modes are 125 | * supported 126 | */ 127 | ms.onSelectionChange = function(item) { 128 | var selectedAttr = ms.sel.selectedAttribute 129 | , ix; 130 | if('single' === ms.sel.mode) { 131 | if(item[selectedAttr]) { 132 | selectedItems.length = 0; 133 | selectedItems.push(item); 134 | } else { 135 | for(ix = selectedItems.length; ix--;) { 136 | if(item[idAttr] === selectedItems[ix][idAttr]) { 137 | selectedItems.splice(ix, 1); 138 | } 139 | } 140 | } 141 | } else { 142 | for(ix = selectedItems.length; ix--;) { 143 | if(selectedItems[ix][idAttr] === item[idAttr]) { 144 | selectedItems.splice(ix, 1); 145 | break; 146 | } 147 | } 148 | if(item[selectedAttr]) { 149 | selectedItems.push(item); 150 | } 151 | } 152 | }; 153 | 154 | /** 155 | * Will be updated as we fetch items 156 | */ 157 | ms.items = []; 158 | 159 | /** 160 | * The size of the *unpaged* collection 161 | * 162 | * The server shoudl tell us how many items are in the collection 163 | * whenever we fetch a new paged set 164 | */ 165 | ms.countItems = 0; 166 | 167 | /** 168 | * Select all (or deselect) *not filtered out* items 169 | * 170 | * Note that if paging is enabled items on other pages will still be 171 | * selected as normal. 172 | * 173 | * Trying to selected all items with a server side paginated dataset is 174 | * pretty gross... we'll do it but split the request up to leverage our 175 | * cluster. 176 | */ 177 | ms.selectAllVisible = function(isSelected) { 178 | isSelected = angular.isDefined(isSelected) ? isSelected : true; 179 | var selectedAttr = ms.sel.selectedAttribute 180 | , ix; 181 | if(isSelected === false && ms.filterString === '') { 182 | for(ix = selectedItems.length; ix--;) { 183 | selectedItems[ix][selectedAttr] = false; 184 | ms.sel.onChange(selectedItems[ix]); 185 | } 186 | selectedItems.length = 0; 187 | } else { 188 | var sizePageHalfTotal = Math.ceil(ms.countItems / 2); 189 | $q.all([ 190 | $scope.getItems({ 191 | filter: ms.filterString, 192 | page: 0, 193 | pageSize: sizePageHalfTotal 194 | }), 195 | $scope.getItems({ 196 | filter: ms.filterString, 197 | page: 1, 198 | pageSize: sizePageHalfTotal 199 | }) 200 | ]) 201 | .then(function(res) { 202 | var incomingItems = res[0].items.concat(res[1].items) 203 | , incomingItemIds = {} 204 | , existingItemIds = {}; 205 | 206 | for(ix = incomingItems.length; ix--;) { 207 | var id = incomingItems[ix][idAttr]; 208 | if(incomingItemIds.hasOwnProperty(id)) { 209 | incomingItems.splice(ix, 1); 210 | } 211 | incomingItemIds[id] = 1; 212 | } 213 | 214 | for(ix = selectedItems.length; ix--;) { 215 | if(incomingItemIds.hasOwnProperty(selectedItems[ix][idAttr])) { 216 | existingItemIds[ selectedItems[ix][idAttr] ] = 1; 217 | selectedItems.splice(ix, 1); 218 | } 219 | } 220 | 221 | if(isSelected) { 222 | Array.prototype.push.apply(selectedItems, incomingItems); 223 | } 224 | 225 | for(ix = ms.items.length; ix--;) { 226 | ms.items[ix][selectedAttr] = isSelected; 227 | if(!existingItemIds.hasOwnProperty(ms.items[ix][idAttr])) { 228 | ms.sel.onChange(ms.items[ix]); 229 | } 230 | } 231 | }); 232 | } 233 | }; 234 | 235 | /** 236 | * Fetch a page of data 237 | * 238 | * Does nothing if the item list is closed. Results will not be 239 | * displayed if there has been a subsequent call to `ms.getItems` 240 | * 241 | * @returns {Promise} Resolves to the current page of items 242 | */ 243 | ms.getItems = function() { 244 | if(!ms.isOpen) { return $q.when(ms.item); } 245 | var fetchedOnCount = ++getItemsCallCount; 246 | return $q.when($scope.getItems({ 247 | filter: ms.filterString, 248 | page: ms.ixPage, 249 | pageSize: ms.sizePage 250 | })) 251 | .then(function(response) { 252 | if(fetchedOnCount !== getItemsCallCount) { 253 | // There has been another call to `getItems` since the one these 254 | // results correspond to. 255 | return; 256 | } 257 | ms.items = response.items; 258 | ms.ixPage = response.page || ms.ixPage; 259 | ms.sizePage = response.pageSize || ms.sizePage; 260 | ms.countItems = response.totalCount || ms.items.length; 261 | if(ms.items.length > ms.sizePage) { 262 | ms.items.length = ms.sizePage; 263 | } 264 | updatePageSelection(); 265 | return ms.items; 266 | }, function(reason) { 267 | ms.items = []; 268 | ms.countItems = 0; 269 | return ms.items; 270 | }); 271 | }; 272 | 273 | // A stamp for `ms.getItems` to verify that the items fetched are still 274 | // considered "fresh". 275 | var getItemsCallCount = 0; 276 | 277 | /** 278 | * Override the hook for filter change 279 | */ 280 | ms.onFilterChange = function() { 281 | ms.ixPage = 0; 282 | ms.getItems(); 283 | }; 284 | 285 | /** 286 | * Get the new page! 287 | */ 288 | ms.onPageChange = function(newPage, oldPage) { 289 | ms.ixPage = newPage; 290 | ms.getItems(); 291 | }; 292 | 293 | /** 294 | * Update our local reference if `selectedItems` changes 295 | * 296 | * @todo Is it cleaner to just use scope.selectedItems everywhere? We 297 | * might still need a watch to update the displayed selected when it 298 | * does change if that's something we want to support 299 | */ 300 | $scope.$watch('selectedItems', function(newVal, oldVal) { 301 | if(newVal && newVal !== oldVal) { 302 | selectedItems = newVal; 303 | updatePageSelection(); 304 | } 305 | }); 306 | }] 307 | }; 308 | }); 309 | 310 | -------------------------------------------------------------------------------- /test/spec/directives/ivh-multi-select-async.js: -------------------------------------------------------------------------------- 1 | 2 | describe('Directive: ivhMultiSelectAsync', function() { 3 | 'use strict'; 4 | 5 | beforeEach(module('ivh.multiSelect')); 6 | beforeEach(module('ivh.pager')); 7 | 8 | var ng = angular 9 | , $ = jQuery 10 | , scope 11 | , c; // compile 12 | 13 | beforeEach(inject(function($rootScope, $compile) { 14 | scope = $rootScope.$new(); 15 | 16 | c = function(tpl, s) { 17 | s = s || scope; 18 | tpl = ng.isArray(tpl) ? tpl.join('\n') : tpl; 19 | var $el = $compile(ng.element(tpl))(s); 20 | s.$apply(); 21 | return $el; 22 | }; 23 | })); 24 | 25 | it('should fetch items when the button is clicked', function() { 26 | var spy = scope.fetcher = jasmine.createSpy('fetcher').and.returnValue({ 27 | page: 0, 28 | pageSize: 10, 29 | totalCount: 500, 30 | items: [] 31 | }); 32 | 33 | var $el = c([ 34 | '
', 36 | 'Blargus', 37 | '
' 38 | ]); 39 | 40 | $el.find('button').click(); 41 | 42 | expect(spy).toHaveBeenCalledWith(jasmine.objectContaining({ 43 | filter: '', // No filters yet 44 | page: 0, // First page 45 | pageSize: jasmine.any(Number) // Don't care about this default 46 | })); 47 | }); 48 | 49 | it('should display fetched items when the button is clicked', function() { 50 | var spy = scope.fetcher = jasmine.createSpy('fetcher').and.returnValue({ 51 | page: 0, 52 | pageSize: 10, 53 | totalCount: 500, 54 | items: [ 55 | {label: 'One'}, 56 | {label: 'Two'}, 57 | {label: 'Three'}, 58 | {label: 'Four'}, 59 | {label: 'Five'}, 60 | {label: 'Six'}, 61 | {label: 'Seven'}, 62 | {label: 'Eight'}, 63 | {label: 'Nine'}, 64 | {label: 'Ten'} 65 | ] 66 | }); 67 | 68 | var $el = c([ 69 | '
', 71 | 'Blargus', 72 | '
' 73 | ]); 74 | 75 | $el.find('button').click(); 76 | 77 | var msItems = $el.find('li.ms-item'); 78 | expect(msItems.length).toBe(10); 79 | expect(msItems.eq(3).text().trim()).toBe('Four'); 80 | }); 81 | 82 | it('should display promised items when the button is clicked', inject(function($q) { 83 | var spy = scope.fetcher = jasmine.createSpy('fetcher').and.returnValue($q.when({ 84 | page: 0, 85 | pageSize: 10, 86 | totalCount: 500, 87 | items: [ 88 | {label: 'One'}, 89 | {label: 'Two'}, 90 | {label: 'Three'}, 91 | {label: 'Four'}, 92 | {label: 'Five'}, 93 | {label: 'Six'}, 94 | {label: 'Seven'}, 95 | {label: 'Eight'}, 96 | {label: 'Nine'}, 97 | {label: 'Ten'} 98 | ] 99 | })); 100 | 101 | var $el = c([ 102 | '
', 104 | 'Blargus', 105 | '
' 106 | ]); 107 | 108 | $el.find('button').click(); 109 | 110 | var msItems = $el.find('li.ms-item'); 111 | expect(msItems.length).toBe(10); 112 | expect(msItems.eq(3).text().trim()).toBe('Four'); 113 | })); 114 | 115 | it('should display paging buttons based on returned info', inject(function($q) { 116 | var spy = scope.fetcher = jasmine.createSpy('fetcher').and.returnValue($q.when({ 117 | page: 0, 118 | pageSize: 10, 119 | totalCount: 500, 120 | items: [ 121 | {label: 'One'}, 122 | {label: 'Two'}, 123 | {label: 'Three'}, 124 | {label: 'Four'}, 125 | {label: 'Five'}, 126 | {label: 'Six'}, 127 | {label: 'Seven'}, 128 | {label: 'Eight'}, 129 | {label: 'Nine'}, 130 | {label: 'Ten'} 131 | ] 132 | })); 133 | 134 | var $el = c([ 135 | '
', 137 | 'Blargus', 138 | '
' 139 | ]); 140 | 141 | $el.find('button').click(); 142 | 143 | var pagingButtons = $el.find('[ivh-pager] li'); 144 | expect(pagingButtons.length).toBe(12); 145 | })); 146 | 147 | it('should fetch new items when the page changes', inject(function($q, $timeout) { 148 | var page0 = $q.when({ 149 | page: 0, 150 | pageSize: 10, 151 | totalCount: 500, 152 | items: [ 153 | {label: '0_One'}, 154 | {label: '0_Two'}, 155 | {label: '0_Three'}, 156 | {label: '0_Four'}, 157 | {label: '0_Five'}, 158 | {label: '0_Six'}, 159 | {label: '0_Seven'}, 160 | {label: '0_Eight'}, 161 | {label: '0_Nine'}, 162 | {label: '0_Ten'} 163 | ] 164 | }); 165 | 166 | var page1 = $q.when({ 167 | page: 1, 168 | pageSize: 10, 169 | totalCount: 500, 170 | items: [ 171 | {label: '1_One'}, 172 | {label: '1_Two'}, 173 | {label: '1_Three'}, 174 | {label: '1_Four'}, 175 | {label: '1_Five'}, 176 | {label: '1_Six'}, 177 | {label: '1_Seven'}, 178 | {label: '1_Eight'}, 179 | {label: '1_Nine'}, 180 | {label: '1_Ten'} 181 | ] 182 | }); 183 | 184 | var spy = scope.fetcher = jasmine.createSpy('fetcher').and.callFake(function(args) { 185 | return args.page === 1 ? page1 : page0; 186 | }); 187 | 188 | var $el = c([ 189 | '
', 191 | 'Blargus', 192 | '
' 193 | ]); 194 | 195 | $el.find('button').click(); 196 | $el.find('[ivh-pager] li a:contains("2")').click(); 197 | 198 | scope.$apply(); 199 | 200 | var msItems = $el.find('li.ms-item'); 201 | expect(msItems.length).toBe(10); 202 | expect(msItems.eq(3).text().trim()).toBe('1_Four'); 203 | })); 204 | 205 | it('should fetch new items when the filter changes', inject(function($q, $timeout) { 206 | var page0 = $q.when({ 207 | page: 0, 208 | pageSize: 10, 209 | totalCount: 30, 210 | items: [ 211 | {label: 'filtered_One'}, 212 | {label: 'filtered_Two'}, 213 | {label: 'filtered_Three'}, 214 | {label: 'filtered_Four'}, 215 | {label: 'filtered_Five'}, 216 | {label: 'filtered_Six'}, 217 | {label: 'filtered_Seven'}, 218 | {label: 'filtered_Eight'}, 219 | {label: 'filtered_Nine'}, 220 | {label: 'filtered_Ten'} 221 | ] 222 | }); 223 | 224 | var spy = scope.fetcher = jasmine.createSpy('fetcher').and.returnValue(page0); 225 | 226 | var $el = c([ 227 | '
', 229 | 'Blargus', 230 | '
' 231 | ]); 232 | 233 | $el.find('button').click(); 234 | 235 | var $msFilter = $el.find('input[type=text]'); 236 | $msFilter.val('foobar'); 237 | $msFilter.change(); 238 | 239 | // The text input is debounced 240 | $timeout.flush(); 241 | 242 | expect(spy).toHaveBeenCalledWith(jasmine.objectContaining({ 243 | filter: 'foobar', 244 | page: 0, 245 | pageSize: jasmine.any(Number) 246 | })); 247 | })); 248 | 249 | it('should fetch new items when the filter is cleared', inject(function($q, $timeout) { 250 | var page0 = $q.when({ 251 | page: 0, 252 | pageSize: 10, 253 | totalCount: 30, 254 | items: [ 255 | {label: 'filtered_One'}, 256 | {label: 'filtered_Two'}, 257 | {label: 'filtered_Three'}, 258 | {label: 'filtered_Four'}, 259 | {label: 'filtered_Five'}, 260 | {label: 'filtered_Six'}, 261 | {label: 'filtered_Seven'}, 262 | {label: 'filtered_Eight'}, 263 | {label: 'filtered_Nine'}, 264 | {label: 'filtered_Ten'} 265 | ] 266 | }); 267 | 268 | var spy = scope.fetcher = jasmine.createSpy('fetcher').and.returnValue(page0); 269 | 270 | var $el = c([ 271 | '
', 273 | 'Blargus', 274 | '
' 275 | ]); 276 | 277 | $el.find('button').click(); 278 | 279 | var $msFilter = $el.find('input[type=text]'); 280 | $msFilter.val('foobar'); 281 | $msFilter.change(); 282 | $timeout.flush(); // text input is debounced 283 | 284 | $el.find('button:contains("Clear")').click(); 285 | 286 | expect(spy.calls.mostRecent().args[0]).toEqual(jasmine.objectContaining({ 287 | filter: '', 288 | page: 0, 289 | pageSize: jasmine.any(Number) 290 | })); 291 | })); 292 | 293 | it('should reset the page when the filter changes', inject(function($timeout) { 294 | var page0 = { 295 | page: 0, 296 | pageSize: 10, 297 | totalCount: 30, 298 | items: [ 299 | {label: 'One'}, 300 | {label: 'Two'}, 301 | {label: 'Three'}, 302 | {label: 'Four'}, 303 | {label: 'Five'}, 304 | {label: 'Six'}, 305 | {label: 'Seven'}, 306 | {label: 'Eight'}, 307 | {label: 'Nine'}, 308 | {label: 'Ten'} 309 | ] 310 | }; 311 | 312 | var spy = scope.fetcher = jasmine.createSpy('fetcher').and.returnValue(page0); 313 | 314 | var $el = c([ 315 | '
', 317 | 'Blargus', 318 | '
' 319 | ]); 320 | 321 | $el.find('button').click(); 322 | 323 | $el.find('[ivh-pager] a:contains("2")').click(); 324 | 325 | var $msFilter = $el.find('input[type=text]'); 326 | $msFilter.val('foobar'); 327 | $msFilter.change(); 328 | 329 | // The text input is debounced 330 | $timeout.flush(); 331 | 332 | var $firstPageLi = $el.find('[ivh-pager] li:contains("1")'); 333 | 334 | expect($firstPageLi.hasClass('active')).toBe(true); 335 | })); 336 | 337 | it('should accept an array of selected items and match by id', function() { 338 | var page0 = { 339 | page: 0, 340 | pageSize: 10, 341 | totalCount: 3, 342 | items: [ 343 | {id: 1, label: 'filtered_One'}, 344 | {id: 2, label: 'filtered_Two'}, 345 | {id: 3, label: 'filtered_Three'} 346 | ] 347 | }; 348 | 349 | var spy = scope.fetcher = jasmine.createSpy('fetcher').and.returnValue(page0); 350 | 351 | scope.mySelection = [ 352 | {id: 2, label: 'filtered_Two'} 353 | ]; 354 | 355 | var $el = c([ 356 | '
', 359 | 'Blargus', 360 | '
' 361 | ]); 362 | 363 | $el.find('button').click(); 364 | 365 | var msCbs = $el.find('li.ms-item input[type=checkbox]'); 366 | expect(msCbs.eq(0).prop('checked')).toBe(false); 367 | expect(msCbs.eq(1).prop('checked')).toBe(true); 368 | expect(msCbs.eq(2).prop('checked')).toBe(false); 369 | }); 370 | 371 | describe('with selected items (single)', function() { 372 | var tpl; 373 | 374 | beforeEach(function() { 375 | scope.fetcher = jasmine.createSpy('fetcher').and.returnValue({ 376 | page: 0, 377 | pageSize: 10, 378 | totalCount: 3, 379 | items: [ 380 | {id: 1, label: 'One'}, 381 | {id: 2, label: 'Two'}, 382 | {id: 3, label: 'Three'} 383 | ] 384 | }); 385 | 386 | tpl = [ 387 | '
', 391 | 'Blargus', 392 | '
' 393 | ]; 394 | }); 395 | 396 | it('should add the last selected item to the list', function() { 397 | var mySelection = scope.mySelection = [{id: 2, label: 'Two'}]; 398 | var $el = c(tpl); 399 | $el.find('button').click(); 400 | $el.find('a:contains("One")').click(); 401 | scope.$apply(); 402 | expect(mySelection.length).toBe(1); 403 | }); 404 | 405 | it('should be able to remove the last item on checkbox click', function() { 406 | var mySelection = scope.mySelection = [{id: 2, label: 'Two'}]; 407 | var $el = c(tpl); 408 | $el.find('button').click(); 409 | $el.find('a:contains("Two") input[type=checkbox]').click(); 410 | scope.$apply(); 411 | expect(mySelection.length).toBe(0); 412 | }); 413 | }); 414 | 415 | describe('with selected items (multi-additive)', function() { 416 | var tpl, spy; 417 | 418 | beforeEach(function() { 419 | var page0 = { 420 | page: 0, 421 | pageSize: 3, 422 | totalCount: 13, 423 | items: [ 424 | {id: 1, label: 'One'}, 425 | {id: 2, label: 'Two'}, 426 | {id: 3, label: 'Three'} 427 | ] 428 | }; 429 | 430 | var page1 = { 431 | page: 1, 432 | pageSize: 3, 433 | totalCount: 13, 434 | items: [ 435 | {id: 4, label: 'Four'}, 436 | {id: 5, label: 'Five'}, 437 | {id: 6, label: 'Six'} 438 | ] 439 | }; 440 | 441 | var half0 = { 442 | page: 0, 443 | pageSize: 7, 444 | totalCount: 13, 445 | items: [ 446 | {id: 1, label: 'One'}, 447 | {id: 2, label: 'Two'}, 448 | {id: 3, label: 'Three'}, 449 | {id: 4, label: 'Four'}, 450 | {id: 5, label: 'Five'}, 451 | {id: 6, label: 'Six'}, 452 | {id: 7, label: 'Seven'} 453 | ] 454 | }; 455 | 456 | var half1 = { 457 | page: 1, 458 | pageSize: 7, 459 | totalCount: 13, 460 | items: [ 461 | {id: 8, label: 'Eight'}, 462 | {id: 9, label: 'Nine'}, 463 | {id: 10, label: 'Ten'}, 464 | {id: 11, label: 'Eleven'}, 465 | {id: 12, label: 'Twelve'}, 466 | {id: 13, label: 'Thirteen'} 467 | ] 468 | }; 469 | 470 | spy = scope.fetcher = jasmine.createSpy('fetcher').and.callFake(function(args) { 471 | if('Thirteen' === args.filter && 0 === args.page) { 472 | return { 473 | page: 0, 474 | pageSize: 7, 475 | totalCount: 1, 476 | items: [{id: 13, label: 'Thirteen'}] 477 | }; 478 | } 479 | 480 | if('Thirteen' === args.filter && 1 === args.page) { 481 | return { 482 | page: 1, 483 | pageSize: 7, 484 | totalCount: 0, 485 | items: [] 486 | }; 487 | } 488 | 489 | if('Send Dups!' === args.filter) { 490 | // Note page0 is sent twice for first and second page requests 491 | return angular.copy(page0); 492 | } 493 | 494 | if(7 === args.pageSize) { 495 | return 0 === args.page ? half0 : half1; 496 | } 497 | // A horrible hack but the default page size is 10 - I don't feel like 498 | // mocking out pages of ten so... 499 | return 0 === args.page ? page0 : page1; 500 | }); 501 | 502 | tpl = [ 503 | '
', 508 | 'Blargus', 509 | '
' 510 | ]; 511 | }); 512 | 513 | it('should add selected items to the list', function() { 514 | var mySelection = scope.mySelection = [{id: 2, label: 'Two'}]; 515 | var $el = c(tpl); 516 | $el.find('button').click(); 517 | $el.find('a:contains("One")').click(); 518 | $el.find('[ivh-pager] li a:contains("2")').click(); 519 | $el.find('a:contains("Six")').click(); 520 | scope.$apply(); 521 | expect(mySelection.length).toBe(3); 522 | }); 523 | 524 | it('should remove unselected items from the list', function() { 525 | var mySelection = scope.mySelection = [{id: 4, label: 'Four'}]; 526 | var $el = c(tpl); 527 | $el.find('button').click(); 528 | $el.find('[ivh-pager] li a:contains("2")').click(); 529 | $el.find('a:contains("Four")').click(); 530 | expect(mySelection.length).toBe(0); 531 | }); 532 | 533 | it('should select all items with two requests', function() { 534 | var mySelection = scope.mySelection = [{id: 4, label: 'Four'}]; 535 | var $el = c(tpl); 536 | $el.find('button').click(); 537 | $el.find('button:contains("All")').click(); 538 | scope.$apply(); 539 | expect(mySelection.length).toBe(13); 540 | }); 541 | 542 | it('should preserve previous selections', inject(function($timeout) { 543 | var mySelection = scope.mySelection = [{id: 4, label: 'Four'}]; 544 | var $el = c(tpl); 545 | $el.find('button').click(); 546 | 547 | var $msFilter = $el.find('input[type=text]'); 548 | $msFilter.val('Thirteen'); 549 | $msFilter.change(); 550 | $timeout.flush(); 551 | 552 | $el.find('button:contains("All")').click(); 553 | scope.$apply(); 554 | 555 | expect(mySelection).toEqual([ 556 | jasmine.objectContaining({id: 4}), 557 | jasmine.objectContaining({id: 13}) 558 | ]); 559 | })); 560 | 561 | it('should guard against incoming duplicates', inject(function($timeout) { 562 | var mySelection = scope.mySelection = []; 563 | var $el = c(tpl); 564 | $el.find('button').click(); 565 | 566 | var $msFilter = $el.find('input[type=text]'); 567 | $msFilter.val('Send Dups!'); 568 | $msFilter.change(); 569 | $timeout.flush(); 570 | 571 | $el.find('button:contains("All")').click(); 572 | scope.$apply(); 573 | 574 | expect(mySelection.length).toBe(3); 575 | })); 576 | 577 | it('should remove all selected items when the remove button is clicked', function() { 578 | var mySelection = scope.mySelection = [{id: 4, label: 'Four'}]; 579 | var $el = c(tpl); 580 | $el.find('button').click(); 581 | $el.find('button:contains("None")').click(); 582 | expect(mySelection.length).toBe(0); 583 | }); 584 | 585 | it('should remove all items including ones not in the set if there is no filter', function() { 586 | var mySelection = scope.mySelection = [{id: 'wow', label: 'Wow'}]; 587 | var $el = c(tpl); 588 | $el.find('button').click(); 589 | $el.find('button:contains("None")').click(); 590 | expect(mySelection.length).toBe(0); 591 | }); 592 | 593 | it('should only remove items in the filtered set if there is a filter', inject(function($timeout) { 594 | var mySelection = scope.mySelection = [{id: 13, label: 'Thirteen'}, {id: 5, label: 'Five'}]; 595 | var $el = c(tpl); 596 | $el.find('button').click(); 597 | 598 | var $msFilter = $el.find('input[type=text]'); 599 | $msFilter.val('Thirteen'); 600 | $msFilter.change(); 601 | $timeout.flush(); 602 | 603 | $el.find('button:contains("None")').click(); 604 | scope.$apply(); 605 | 606 | expect(mySelection).toEqual([ 607 | jasmine.objectContaining({id: 5}) 608 | ]); 609 | })); 610 | 611 | it('should fire `on-change` for newly selected items (non-visible)', function() { 612 | var changedItems = []; 613 | scope.onChange = function(item) { changedItems.push(item); }; 614 | var $el = c(tpl); 615 | $el.find('button').click(); 616 | $el.find('button:contains("All")').click(); 617 | scope.$apply(); 618 | expect(changedItems.length).toBe(6); 619 | }); 620 | 621 | it('should respect reference changes on the list of selected items', function() { 622 | scope.mySelection = [{id: 4, label: 'Four'}]; 623 | var $el = c(tpl); 624 | $el.find('button').click(); 625 | scope.mySelection = [{id: 1}, {id: 2}]; 626 | scope.$apply(); 627 | expect($el.find(':checked').length).toBe(2); 628 | }); 629 | }); 630 | 631 | it('should always show results from the most recent search', inject(function($q, $timeout) { 632 | /** 633 | * @see https://github.com/iVantage/angular-ivh-multi-select/issues/7 634 | */ 635 | var deferred1 = $q.defer() 636 | , deferred2 = $q.defer(); 637 | 638 | var results1 = { 639 | page: 0, 640 | pageSize: 10, 641 | totalCount: 3, 642 | items: [ 643 | {id: 1, label: 'res_One'}, 644 | {id: 2, label: 'res_Two'}, 645 | {id: 3, label: 'res_Three'} 646 | ] 647 | }; 648 | 649 | var results2 = { 650 | page: 0, 651 | pageSize: 10, 652 | totalCount: 3, 653 | items: [ 654 | {id: 4, label: 'res_Four'}, 655 | {id: 5, label: 'res_Five'}, 656 | {id: 6, label: 'res_Six'} 657 | ] 658 | }; 659 | 660 | scope.fetcher = jasmine.createSpy('fetcher').and.callFake(function(args) { 661 | return args.filter === 'results2' ? deferred2.promise : deferred1.promise; 662 | }); 663 | 664 | var $el = c([ 665 | '
', 667 | 'Blargus', 668 | '
' 669 | ]); 670 | 671 | $el.find('button').click(); 672 | 673 | var $msFilter = $el.find('input[type=text]'); 674 | $msFilter.val('results1'); 675 | $msFilter.change(); 676 | $timeout.flush(); 677 | 678 | $msFilter.val('results2'); 679 | $msFilter.change(); 680 | $timeout.flush(); 681 | 682 | deferred2.resolve(results2); 683 | scope.$apply(); 684 | 685 | deferred1.resolve(results1); 686 | scope.$apply(); 687 | 688 | var msItems = $el.find('li.ms-item'); 689 | expect(msItems.eq(0).text().trim()).toBe('res_Four'); 690 | })); 691 | }); 692 | 693 | -------------------------------------------------------------------------------- /dist/ivh-multi-select.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Main module declaration for ivh.multiSelect 4 | * 5 | * @package ivh.multiSelect 6 | * @copyright 2015 iVantage Health Analytics, Inc. 7 | */ 8 | 9 | angular.module('ivh.multiSelect', [ 10 | 'selectionModel' 11 | ]); 12 | 13 | 14 | /** 15 | * The (Async) Multi Select directive 16 | * 17 | * @package ivh.multiSelect 18 | * @copyright 2015 iVantage Health Analytics, Inc. 19 | */ 20 | 21 | angular.module('ivh.multiSelect') 22 | .directive('ivhMultiSelectAsync', function() { 23 | 'use strict'; 24 | return { 25 | scope: { 26 | labelAttr: '=ivhMultiSelectLabelAttribute', 27 | labelExpr: '=ivhMultiSelectLabelExpression', 28 | 29 | /** 30 | * Used to compare freshly paged in collection items with those in the 31 | * selected items array 32 | */ 33 | idAttr: '=ivhMultiSelectIdAttribute', 34 | 35 | /** 36 | * A managed list of currently selected items 37 | */ 38 | selectedItems: '=ivhMultiSelectSelectedItems', 39 | 40 | /** 41 | * The function we'll use to fetch pages of items 42 | * 43 | * Should accept an options object with the following properties: 44 | * 45 | * - filter: A string, whatever the user has entered in the filte box 46 | * - page: The zero-based page number we're requesting for paginated 47 | * results. 48 | * - pageSize: The number of items we expect per page 49 | * 50 | * The function should return an object, or promise which resolves to 51 | * shuch an object, with the following properties: 52 | * 53 | * - items: A page of collection items, if more than one page was 54 | * returned only the first `pageSize` will be displayed (assuming 55 | * paging is enabled). 56 | * - page: [Optional] The zero-based page number corresponding to the 57 | * returned results. If ommitted and paging is enabled we will assume 58 | * `page` from the request options. 59 | * - pageSize: The size of a page of results, if omitted we will assume 60 | * `pageSize` from the request options. 61 | * - `totalCount`: The total (unpaged) result set count 62 | * 63 | */ 64 | getItems: '=ivhMultiSelectFetcher', 65 | 66 | /** 67 | * Options for selection model 68 | */ 69 | selectionModelType: '=', 70 | selectionModelMode: '=', 71 | selectionModelSelectedAttribute: '=', 72 | selectionModelSelectedClass: '=', 73 | selectionModelCleanupStrategy: '=', 74 | 75 | /** 76 | * Should be an angular expression in which `item` is the collection 77 | * item that has changed selected state 78 | */ 79 | selOnChange: '&selectionModelOnChange' 80 | }, 81 | restrict: 'AE', 82 | template: '\n\n', 83 | transclude: true, 84 | controllerAs: 'ms', 85 | controller: ['$document', '$scope', '$q', 'ivhMultiSelectCore', 86 | function($document, $scope, $q, ivhMultiSelectCore) { 87 | 88 | /** 89 | * Mixin core functionality 90 | */ 91 | var ms = this; 92 | ivhMultiSelectCore.init(ms, $scope); 93 | 94 | /** 95 | * Async flavor supports only 'multi-additive' and 'single' selection 96 | * model modes. 97 | * 98 | * @todo blow up if we've been given another mode. 99 | */ 100 | 101 | /** 102 | * The async version of multi-select can't rely on a local collection 103 | * 104 | * Instead we work with an array of selected items. These will be matche 105 | * up with items fetched from th server by their ID attribute. 106 | * 107 | * As the user selects and deselected items those items will be added 108 | * and removed from the array of selected items. 109 | */ 110 | var idAttr = $scope.idAttr || 'id' 111 | , selectedItems = $scope.selectedItems || []; 112 | 113 | /** 114 | * If we are tracking a selection update the new page of things 115 | */ 116 | var itemIsSelected = function(item) { 117 | for(var ix = selectedItems.length; ix--;) { 118 | if(selectedItems[ix][idAttr] === item[idAttr]) { 119 | return true; 120 | } 121 | } 122 | return false; 123 | }; 124 | 125 | var updatePageSelection = function() { 126 | var selectedAttr = ms.sel.selectedAttribute; 127 | for(var ix = ms.items.length; ix--;) { 128 | ms.items[ix][selectedAttr] = itemIsSelected(ms.items[ix]); 129 | } 130 | }; 131 | 132 | /** 133 | * Update the selection as reported to the user whenever we have 134 | * selection model updates 135 | * 136 | * Note that only single and multi-additive selection modes are 137 | * supported 138 | */ 139 | ms.onSelectionChange = function(item) { 140 | var selectedAttr = ms.sel.selectedAttribute 141 | , ix; 142 | if('single' === ms.sel.mode) { 143 | if(item[selectedAttr]) { 144 | selectedItems.length = 0; 145 | selectedItems.push(item); 146 | } else { 147 | for(ix = selectedItems.length; ix--;) { 148 | if(item[idAttr] === selectedItems[ix][idAttr]) { 149 | selectedItems.splice(ix, 1); 150 | } 151 | } 152 | } 153 | } else { 154 | for(ix = selectedItems.length; ix--;) { 155 | if(selectedItems[ix][idAttr] === item[idAttr]) { 156 | selectedItems.splice(ix, 1); 157 | break; 158 | } 159 | } 160 | if(item[selectedAttr]) { 161 | selectedItems.push(item); 162 | } 163 | } 164 | }; 165 | 166 | /** 167 | * Will be updated as we fetch items 168 | */ 169 | ms.items = []; 170 | 171 | /** 172 | * The size of the *unpaged* collection 173 | * 174 | * The server shoudl tell us how many items are in the collection 175 | * whenever we fetch a new paged set 176 | */ 177 | ms.countItems = 0; 178 | 179 | /** 180 | * Select all (or deselect) *not filtered out* items 181 | * 182 | * Note that if paging is enabled items on other pages will still be 183 | * selected as normal. 184 | * 185 | * Trying to selected all items with a server side paginated dataset is 186 | * pretty gross... we'll do it but split the request up to leverage our 187 | * cluster. 188 | */ 189 | ms.selectAllVisible = function(isSelected) { 190 | isSelected = angular.isDefined(isSelected) ? isSelected : true; 191 | var selectedAttr = ms.sel.selectedAttribute 192 | , ix; 193 | if(isSelected === false && ms.filterString === '') { 194 | for(ix = selectedItems.length; ix--;) { 195 | selectedItems[ix][selectedAttr] = false; 196 | ms.sel.onChange(selectedItems[ix]); 197 | } 198 | selectedItems.length = 0; 199 | } else { 200 | var sizePageHalfTotal = Math.ceil(ms.countItems / 2); 201 | $q.all([ 202 | $scope.getItems({ 203 | filter: ms.filterString, 204 | page: 0, 205 | pageSize: sizePageHalfTotal 206 | }), 207 | $scope.getItems({ 208 | filter: ms.filterString, 209 | page: 1, 210 | pageSize: sizePageHalfTotal 211 | }) 212 | ]) 213 | .then(function(res) { 214 | var incomingItems = res[0].items.concat(res[1].items) 215 | , incomingItemIds = {} 216 | , existingItemIds = {}; 217 | 218 | for(ix = incomingItems.length; ix--;) { 219 | var id = incomingItems[ix][idAttr]; 220 | if(incomingItemIds.hasOwnProperty(id)) { 221 | incomingItems.splice(ix, 1); 222 | } 223 | incomingItemIds[id] = 1; 224 | } 225 | 226 | for(ix = selectedItems.length; ix--;) { 227 | if(incomingItemIds.hasOwnProperty(selectedItems[ix][idAttr])) { 228 | existingItemIds[ selectedItems[ix][idAttr] ] = 1; 229 | selectedItems.splice(ix, 1); 230 | } 231 | } 232 | 233 | if(isSelected) { 234 | Array.prototype.push.apply(selectedItems, incomingItems); 235 | } 236 | 237 | for(ix = ms.items.length; ix--;) { 238 | ms.items[ix][selectedAttr] = isSelected; 239 | if(!existingItemIds.hasOwnProperty(ms.items[ix][idAttr])) { 240 | ms.sel.onChange(ms.items[ix]); 241 | } 242 | } 243 | }); 244 | } 245 | }; 246 | 247 | /** 248 | * Fetch a page of data 249 | * 250 | * Does nothing if the item list is closed. Results will not be 251 | * displayed if there has been a subsequent call to `ms.getItems` 252 | * 253 | * @returns {Promise} Resolves to the current page of items 254 | */ 255 | ms.getItems = function() { 256 | if(!ms.isOpen) { return $q.when(ms.item); } 257 | var fetchedOnCount = ++getItemsCallCount; 258 | return $q.when($scope.getItems({ 259 | filter: ms.filterString, 260 | page: ms.ixPage, 261 | pageSize: ms.sizePage 262 | })) 263 | .then(function(response) { 264 | if(fetchedOnCount !== getItemsCallCount) { 265 | // There has been another call to `getItems` since the one these 266 | // results correspond to. 267 | return; 268 | } 269 | ms.items = response.items; 270 | ms.ixPage = response.page || ms.ixPage; 271 | ms.sizePage = response.pageSize || ms.sizePage; 272 | ms.countItems = response.totalCount || ms.items.length; 273 | if(ms.items.length > ms.sizePage) { 274 | ms.items.length = ms.sizePage; 275 | } 276 | updatePageSelection(); 277 | return ms.items; 278 | }, function(reason) { 279 | ms.items = []; 280 | ms.countItems = 0; 281 | return ms.items; 282 | }); 283 | }; 284 | 285 | // A stamp for `ms.getItems` to verify that the items fetched are still 286 | // considered "fresh". 287 | var getItemsCallCount = 0; 288 | 289 | /** 290 | * Override the hook for filter change 291 | */ 292 | ms.onFilterChange = function() { 293 | ms.ixPage = 0; 294 | ms.getItems(); 295 | }; 296 | 297 | /** 298 | * Get the new page! 299 | */ 300 | ms.onPageChange = function(newPage, oldPage) { 301 | ms.ixPage = newPage; 302 | ms.getItems(); 303 | }; 304 | 305 | /** 306 | * Update our local reference if `selectedItems` changes 307 | * 308 | * @todo Is it cleaner to just use scope.selectedItems everywhere? We 309 | * might still need a watch to update the displayed selected when it 310 | * does change if that's something we want to support 311 | */ 312 | $scope.$watch('selectedItems', function(newVal, oldVal) { 313 | if(newVal && newVal !== oldVal) { 314 | selectedItems = newVal; 315 | updatePageSelection(); 316 | } 317 | }); 318 | }] 319 | }; 320 | }); 321 | 322 | 323 | 324 | /** 325 | * Listen for clicks outside the multi-select and collapse it if needed 326 | * 327 | * @package ivh.multiSelect 328 | * @copyright 2015 iVantage Health Analytics, Inc. 329 | */ 330 | 331 | angular.module('ivh.multiSelect') 332 | .directive('ivhMultiSelectCollapsable', ['$document', function($document) { 333 | 'use strict'; 334 | return { 335 | restrict: 'A', 336 | require: ['?^ivhMultiSelect', '?^ivhMultiSelectAsync'], 337 | link: function(scope, element, attrs, ctrls) { 338 | 339 | /** 340 | * Clicks on the body should close this multiselect 341 | * 342 | * ... unless the element has been tagged with 343 | * ivh-multi-select-stay-open... ;) 344 | * 345 | * Be a good doobee and clean up this click handler when our scope is 346 | * destroyed 347 | */ 348 | var $bod = $document.find('body'); 349 | 350 | var collapseMe = function($event) { 351 | var evt = $event.originalEvent || $event; 352 | if(!evt.ivhMultiSelectIgnore) { 353 | // Only one of the required parent controllers will be defined 354 | angular.forEach(ctrls, function(ms) { 355 | if(ms) { ms.isOpen = false; } 356 | }); 357 | scope.$digest(); 358 | } 359 | }; 360 | 361 | $bod.on('click', collapseMe); 362 | 363 | scope.$on('$destroy', function() { 364 | $bod.off('click', collapseMe); 365 | }); 366 | } 367 | }; 368 | }]); 369 | 370 | 371 | 372 | 373 | 374 | /** 375 | * MS Filter 376 | * 377 | * Consolidated to share between sync and async multiselect 378 | * 379 | * @package ivh.multiSelect 380 | * @copyright 2015 iVantage Health Analytics, Inc. 381 | */ 382 | 383 | angular.module('ivh.multiSelect') 384 | .directive('ivhMultiSelectFilter', function() { 385 | 'use strict'; 386 | return { 387 | restrict: 'A', 388 | template: '\n\n\n\n' 389 | }; 390 | }); 391 | 392 | 393 | /** 394 | * Displays a "no results" message 395 | * 396 | * Consolidated to share between sync and async multiselect 397 | * 398 | * @package ivh.multiSelect 399 | * @copyright 2015 iVantage Health Analytics, Inc. 400 | */ 401 | 402 | angular.module('ivh.multiSelect') 403 | .directive('ivhMultiSelectNoResults', function() { 404 | 'use strict'; 405 | return { 406 | restrict: 'A', 407 | template: '\n\nNothing to show\n\n\n' 408 | }; 409 | }); 410 | 411 | 412 | 413 | /** 414 | * Don't close the multiselect on click 415 | * 416 | * @package ivh.multiSelect 417 | * @copyright 2015 iVantage Health Analytics, Inc. 418 | */ 419 | 420 | angular.module('ivh.multiSelect') 421 | .directive('ivhMultiSelectStayOpen', function() { 422 | 'use strict'; 423 | return { 424 | restrict: 'A', 425 | link: function(scope, element, attrs) { 426 | 427 | /** 428 | * Clicks on this element should not cause the multi-select to close 429 | */ 430 | element.on('click', function($event) { 431 | var evt = $event.originalEvent || $event; 432 | evt.ivhMultiSelectIgnore = true; 433 | }); 434 | } 435 | }; 436 | }); 437 | 438 | 439 | 440 | /** 441 | * MS Tools (e.g. "Select All" button) 442 | * 443 | * Consolidated to share between sync and async multiselect 444 | * 445 | * @package ivh.multiSelect 446 | * @copyright 2015 iVantage Health Analytics, Inc. 447 | */ 448 | 449 | angular.module('ivh.multiSelect') 450 | .directive('ivhMultiSelectTools', function() { 451 | 'use strict'; 452 | return { 453 | restrict: 'A', 454 | template: '\n\n\n\n\n' 455 | }; 456 | }); 457 | 458 | 459 | 460 | 461 | /** 462 | * The Multi Select directive 463 | * 464 | * @package ivh.multiSelect 465 | * @copyright 2015 iVantage Health Analytics, Inc. 466 | */ 467 | 468 | angular.module('ivh.multiSelect') 469 | .directive('ivhMultiSelect', function() { 470 | 'use strict'; 471 | return { 472 | scope: { 473 | labelAttr: '=ivhMultiSelectLabelAttribute', 474 | labelExpr: '=ivhMultiSelectLabelExpression', 475 | 476 | /** 477 | * The universe of items 478 | */ 479 | items: '=ivhMultiSelectItems', 480 | 481 | /** 482 | * Options for selection model 483 | */ 484 | selectionModelType: '=', 485 | selectionModelMode: '=', 486 | selectionModelSelectedAttribute: '=', 487 | selectionModelSelectedClass: '=', 488 | selectionModelCleanupStrategy: '=', 489 | selectionModelSelectedItems: '=', 490 | 491 | /** 492 | * Should be an angular expression in which `item` is the collection 493 | * item that has changed selected state 494 | */ 495 | selOnChange: '&selectionModelOnChange' 496 | }, 497 | restrict: 'AE', 498 | template: '\n\n', 499 | transclude: true, 500 | controllerAs: 'ms', 501 | controller: ['$document', '$scope', 'ivhMultiSelectCore', 502 | function($document, $scope, ivhMultiSelectCore) { 503 | 504 | /** 505 | * Mixin core functionality 506 | */ 507 | var ms = this; 508 | ivhMultiSelectCore.init(ms, $scope); 509 | 510 | /** 511 | * Attach the passed items to our controller for consistent interface 512 | * 513 | * Will be updated from the view as `$scope.items` changes 514 | */ 515 | ms.items = $scope.items; 516 | 517 | /** 518 | * Select all (or deselect) *not filtered out* items 519 | * 520 | * Note that if paging is enabled items on other pages will still be 521 | * selected as normal. 522 | */ 523 | ms.selectAllVisible = function(isSelected) { 524 | isSelected = angular.isDefined(isSelected) ? isSelected : true; 525 | var selectedAttr = ms.sel.selectedAttribute; 526 | angular.forEach(ms.items, function(item) { 527 | item[selectedAttr] = isSelected; 528 | ms.sel.onChange(item); 529 | }); 530 | }; 531 | }] 532 | }; 533 | }); 534 | 535 | 536 | /** 537 | * For when you really need to track ids of selected items 538 | * 539 | * A bit of a hack, meant to be used in conjunction with 540 | * `selection-model-on-change`: 541 | * 542 | * ``` 543 | *
546 | *
547 | * ``` 548 | * 549 | * @package ivh.multiSelect 550 | * @copyright 2015 iVantage Health Analytics, Inc. 551 | */ 552 | 553 | angular.module('ivh.multiSelect') 554 | .filter('ivhMultiSelectCollect', ['selectionModelOptions', function(selectionModelOptions) { 555 | 'use strict'; 556 | 557 | var defaultSelAttr = selectionModelOptions.get().selectedAttribute; 558 | 559 | return function(idsList, item, idAttr, selAttr) { 560 | if(!idsList || !item) { 561 | return idsList; 562 | } 563 | 564 | var isSelected = item[selAttr || defaultSelAttr] 565 | , itemId = item[idAttr || 'id'] 566 | , ixId = idsList.indexOf(itemId); 567 | 568 | if(isSelected && -1 === ixId) { 569 | idsList.push(itemId); 570 | } else if(!isSelected && ixId > -1) { 571 | idsList.splice(ixId, 1); 572 | } 573 | 574 | return idsList; 575 | }; 576 | }]); 577 | 578 | 579 | 580 | /** 581 | * For filtering items by calculated labels 582 | * 583 | * @package ivh.multiSelect 584 | * @copyright 2016 iVantage Health Analytics, Inc. 585 | */ 586 | 587 | angular.module('ivh.multiSelect') 588 | .filter('ivhMultiSelectLabelFilter', [function(selectionModelOptions) { 589 | 'use strict'; 590 | 591 | return function(items, ctrl) { 592 | var str = ctrl.filterString; 593 | 594 | if(!items || !str) { 595 | return items; 596 | } 597 | 598 | var filtered = []; 599 | 600 | angular.forEach(items, function(item) { 601 | if(ctrl.getLabelFor(item).indexOf(str) > -1) { 602 | filtered.push(item); 603 | } 604 | }); 605 | 606 | return filtered; 607 | }; 608 | }]); 609 | 610 | 611 | 612 | 613 | /** 614 | * Wrapper for ivhPaginateFilter if present 615 | * 616 | * @package ivh.multiSelect 617 | * @copyright 2015 iVantage Health Analytics, Inc. 618 | */ 619 | 620 | angular.module('ivh.multiSelect') 621 | .filter('ivhMultiSelectPaginate', ['$injector', function($injector) { 622 | 'use strict'; 623 | 624 | // Fall back to the identity function 625 | var filterFn = function(col) { 626 | return col; 627 | }; 628 | 629 | // Use ivhPaginateFilter if we have access to it 630 | if($injector.has('ivhPaginateFilter')) { 631 | filterFn = $injector.get('ivhPaginateFilter'); 632 | } 633 | 634 | return filterFn; 635 | }]); 636 | 637 | 638 | /** 639 | * Shared multi select controller functionality 640 | * 641 | * @package ivh.multiSelect 642 | * @copyright 2015 iVantage Health Analytics, Inc. 643 | */ 644 | 645 | angular.module('ivh.multiSelect') 646 | .factory('ivhMultiSelectCore', ['$injector', '$interpolate', 'ivhMultiSelectSelm', 647 | function($injector, $interpolate, ivhMultiSelectSelm) { 648 | 'use strict'; 649 | var exports = {}; 650 | 651 | /** 652 | * Adds shared functionality a multiselect controller 653 | */ 654 | exports.init = function(ms, $scope) { 655 | var pagerPageSize = 10 656 | , pagerUsePager = true; 657 | 658 | /** 659 | * Whether or not the dropdown is displayed 660 | * 661 | * See ivh-multi-select-collapsable 662 | * 663 | * Toggled whenever the user clicks the ol' button 664 | */ 665 | ms.isOpen = false; 666 | 667 | /** 668 | * The filter string entered by the user into our input control 669 | */ 670 | ms.filterString = ''; 671 | 672 | /** 673 | * We're embedding selection-model 674 | * 675 | * Forward supported `selection-model-*` attributes to the underlying 676 | * directive. 677 | */ 678 | ms.sel = ivhMultiSelectSelm.options($scope); 679 | 680 | /** 681 | * Disable the 'All'/'None' buttons when in single select mode 682 | */ 683 | ms.enableMultiSelect = 'single' !== ms.sel.mode; 684 | 685 | /** 686 | * Filter change hook, override as needed. 687 | * 688 | * Defined in core so as not to generate errors 689 | */ 690 | ms.onFilterChange = angular.noop; 691 | 692 | /** 693 | * Setup watchers for each selection model propety attached to us 694 | */ 695 | angular.forEach(ivhMultiSelectSelm.propsMap(), function(p) { 696 | var unwatch = $scope.$watch(p[1], function(newVal) { 697 | if(newVal) { 698 | ms.sel[p[0]] = newVal; 699 | if('mode' === p[0]) { 700 | ms.enableMultiSelect = 'single' !== newVal; 701 | } 702 | } 703 | }); 704 | $scope.$on('$destroy', unwatch); 705 | }); 706 | 707 | /** 708 | * Provide a way for the outside world to know about selection changes 709 | */ 710 | ms.sel.onChange = function(item) { 711 | $scope.selOnChange({item: item}); 712 | }; 713 | 714 | /** 715 | * The collection item attribute or expression to display as a label 716 | */ 717 | var labelAttr, labelFn; 718 | 719 | ms.getLabelFor = function(item) { 720 | return labelFn ? labelFn({item: item}) : item[labelAttr]; 721 | }; 722 | 723 | $scope.$watch('labelExpr || labelAttr', function() { 724 | labelAttr = $scope.labelAttr || 'label'; 725 | labelFn = $scope.labelExpr ? $interpolate($scope.labelExpr) : null; 726 | }); 727 | 728 | /** 729 | * We optionally suppor the ivh.pager module 730 | * 731 | * If it is present your items will be paged otherwise all are displayed 732 | */ 733 | ms.hasPager = pagerUsePager && $injector.has('ivhPaginateFilter'); 734 | ms.ixPage = 0; 735 | ms.sizePage = pagerPageSize; 736 | }; 737 | 738 | return exports; 739 | }]); 740 | 741 | 742 | 743 | /** 744 | * Selection Model helpers for Multi Select 745 | * 746 | * @package ivh.multiSelect 747 | * @copyright 2015 iVantage Health Analytics, Inc. 748 | */ 749 | 750 | angular.module('ivh.multiSelect') 751 | .factory('ivhMultiSelectSelm', ['selectionModelOptions', function(selectionModelOptions) { 752 | 'use strict'; 753 | var exports = {}; 754 | 755 | /** 756 | * We're overriding selection model defaults with our own 757 | * 758 | * May still be set by the user at the attribute level 759 | */ 760 | var selmOverrides = { 761 | type: 'checkbox', 762 | mode: 'multi-additive' 763 | }; 764 | 765 | /** 766 | * Returns the supported selection model properties 767 | * 768 | * Note that we're only interested in properties that may need to be watched 769 | * (i.e. `selection-model-on-change` is omitted) 770 | * 771 | * @return {Array} The list of props, look for {1} $scope and {0} on selection model props 772 | */ 773 | exports.propsMap = function() { 774 | return [ 775 | ['type', 'selectionModelType'], 776 | ['mode', 'selectionModelMode'], 777 | ['selectedAttribute', 'selectionModelSelectedAttribute'], 778 | ['selectedClass', 'selectionModelSelectedClass'], 779 | ['cleanupStategy', 'selectionModelCleanupStrategy'], 780 | ['selectedItems', 'selectionModelSelectedItems'] 781 | ]; 782 | }; 783 | 784 | /** 785 | * Merges and returns selection model defaults with overrides on the passed 786 | * scope. 787 | * 788 | * Accounts for IVH Multi Select selection model defaults 789 | * 790 | * @param {Scope} $scope Should have props matching supported selection model attrs 791 | * @return {Opbject} A hash of the merged options 792 | */ 793 | exports.options = function($scope) { 794 | var opts = angular.extend({}, selectionModelOptions.get(), selmOverrides); 795 | angular.forEach(exports.propsMap(), function(p) { 796 | if($scope[p[1]]) { 797 | opts[p[0]] = $scope[p[1]]; 798 | } 799 | }); 800 | return opts; 801 | }; 802 | 803 | return exports; 804 | }]); 805 | --------------------------------------------------------------------------------