├── .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 |
5 |
6 | All
7 |
8 |
9 |
12 |
13 | None
14 |
15 |
16 |
19 | Clear
20 |
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 |
4 |
7 |
8 |
9 |
10 |
11 |
47 |
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 |
4 |
7 |
8 |
9 |
10 |
11 |
47 |
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 | [](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\n \n \n \n\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 \nAll\n \n\n \nNone\n \n\nClear\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\n \n \n \n\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\n \n \n \n\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 \nAll\n \n\n \nNone\n \n\nClear\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\n \n \n \n\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 |
--------------------------------------------------------------------------------