├── .bowerrc ├── .editorconfig ├── .gitattributes ├── .gitignore ├── .jshintrc ├── .travis.yml ├── CHANGELOG.md ├── Gruntfile.js ├── LICENSE ├── README.md ├── bower.json ├── common └── module.js ├── dist ├── angular-bootstrap-duallistbox.js └── angular-bootstrap-duallistbox.min.js ├── example ├── index.html ├── scripts │ ├── app.js │ └── controllers │ │ └── main.js └── styles │ └── main.css ├── karma.conf.js ├── package.json ├── src └── directives │ └── bsDuallistbox.js └── test ├── .jshintrc ├── runner.html └── spec └── directives └── bsDuallistboxSpec.js /.bowerrc: -------------------------------------------------------------------------------- 1 | { 2 | "directory": "bower_components" 3 | } 4 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | bower_components 2 | node_modules 3 | .tmp 4 | .sass-cache 5 | .idea/ 6 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "node": true, 3 | "browser": true, 4 | "esnext": true, 5 | "bitwise": true, 6 | "camelcase": true, 7 | "curly": true, 8 | "eqeqeq": true, 9 | "immed": true, 10 | "indent": 2, 11 | "latedef": true, 12 | "newcap": true, 13 | "noarg": true, 14 | "quotmark": "single", 15 | "regexp": true, 16 | "undef": true, 17 | "unused": true, 18 | "strict": true, 19 | "trailing": true, 20 | "smarttabs": true, 21 | "globals": { 22 | "angular": false, 23 | "$": false, 24 | "jQuery": false 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - '0.10' 5 | 6 | before_script: 7 | - export DISPLAY=:99.0 8 | - export PHANTOMJS_BIN=/usr/local/phantomjs/bin/phantomjs 9 | - sh -e /etc/init.d/xvfb start 10 | - sleep 3 # give xvfb some time to start 11 | - 'npm install -g bower grunt-cli' 12 | - 'npm install' 13 | - 'bower install' 14 | 15 | script: 16 | - grunt -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | CHANGELOG 2 | ========= 3 | 4 | # 0.1.0 (2015-06-13) 5 | 6 | - Update to angular-1.4.0 7 | 8 | # 0.0.3 (2014-11-20) 9 | 10 | - Update to angular-1.3.3 11 | 12 | # 0.0.2 (2014-03-27) 13 | 14 | - Update to bootstrap-duallistbox-3.0.1 15 | 16 | # 0.0.1 (2014-02-04) 17 | 18 | - First release 19 | -------------------------------------------------------------------------------- /Gruntfile.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function (grunt) { 4 | // load all grunt tasks 5 | require('matchdep').filterDev('grunt-*').forEach(grunt.loadNpmTasks); 6 | 7 | // configurable paths 8 | var yeomanConfig = { 9 | src: 'src', 10 | dist: 'dist', 11 | test: 'test', 12 | temp: '.temp' 13 | }; 14 | 15 | try { 16 | yeomanConfig.src = require('./bower.json').appPath || yeomanConfig.src; 17 | } catch (e) {} 18 | 19 | grunt.initConfig({ 20 | yeoman: yeomanConfig, 21 | pkg: grunt.file.readJSON('bower.json'), 22 | meta: { 23 | banner: 24 | '/**\n' + 25 | ' * <%= pkg.name %>\n' + 26 | ' * @version v<%= pkg.version %> - <%= grunt.template.today("yyyy-mm-dd") %>\n' + 27 | ' * @author <%= pkg.author.name %> (<%= pkg.author.email %>)\n' + 28 | ' * @link <%= pkg.homepage %>\n' + 29 | ' * @license <%= _.pluck(pkg.licenses, "type").join(", ") %>\n' + 30 | '**/\n\n' 31 | }, 32 | jshint: { 33 | options: { 34 | jshintrc: '.jshintrc' 35 | }, 36 | all: [ 37 | 'Gruntfile.js', 38 | '<%= yeoman.src %>/**/*.js' 39 | ], 40 | test: { 41 | src: ['<%= yeoman.test %>/spec/**/*.js'], 42 | options: { 43 | jshintrc: '<%= yeoman.test %>/.jshintrc' 44 | } 45 | } 46 | }, 47 | karma: { 48 | options: { 49 | configFile: 'karma.conf.js' 50 | }, 51 | unit: { 52 | options: { 53 | singleRun: false 54 | } 55 | }, 56 | phantom: { 57 | browsers: ['PhantomJS'] 58 | } 59 | }, 60 | clean: { 61 | dist: { 62 | files: [{ 63 | dot: true, 64 | src: [ 65 | '<%= yeoman.dist %>/*', 66 | '!<%= yeoman.dist %>/.git*' 67 | ] 68 | }] 69 | }, 70 | temp: { 71 | src: ['<%= yeoman.dist %>/<%= yeoman.temp %>'] 72 | } 73 | }, 74 | ngmin: { 75 | dist: { 76 | expand: true, 77 | cwd: '<%= yeoman.src %>', 78 | src: ['**/*.js'], 79 | dest: '<%= yeoman.dist %>/<%= yeoman.temp %>' 80 | } 81 | }, 82 | concat: { 83 | options: { 84 | banner: '<%= meta.banner %>\'use strict\';\n', 85 | process: function(src, filepath) { 86 | return '// Source: ' + filepath + '\n' + 87 | src.replace(/(^|\n)[ \t]*('use strict'|"use strict");?\s*/g, '$1'); 88 | } 89 | }, 90 | dist: { 91 | src: ['common/*.js', '<%= yeoman.dist %>/<%= yeoman.temp %>/**/*.js'], 92 | dest: '<%= yeoman.dist %>/<%= pkg.name %>.js' 93 | } 94 | }, 95 | uglify: { 96 | options: { 97 | banner: '<%= meta.banner %>' 98 | }, 99 | min: { 100 | files: { 101 | '<%= yeoman.dist %>/<%= pkg.name %>.min.js': '<%= concat.dist.dest %>' 102 | } 103 | } 104 | } 105 | }); 106 | 107 | // Test the directive 108 | grunt.registerTask('test', ['jshint', 'karma:unit']); 109 | grunt.registerTask('test-travis', ['jshint', 'karma:phantom']); 110 | 111 | // Build the directive 112 | // - clean, cleans the output directory 113 | // - ngmin, prepares the angular files 114 | // - concat, concatenates and adds a banner to the debug file 115 | // - uglify, minifies and adds a banner to the minified file 116 | // - clean:temp, cleans the ngmin-ified directory 117 | grunt.registerTask('build', ['clean', 'ngmin', 'concat', 'uglify', 'clean:temp']); 118 | 119 | // Default task, do everything 120 | grunt.registerTask('default', ['test-travis', 'build']); 121 | }; 122 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | angular-bootstrap-duallistbox [![Build Status](https://travis-ci.org/frapontillo/angular-bootstrap-duallistbox.png)](https://travis-ci.org/frapontillo/angular-bootstrap-duallistbox) 2 | ======================== 3 | 4 | Angular directive to include [Bootstrap Dual Listbox](https://github.com/istvan-ujjmeszaros/bootstrap-duallistbox) in your apps. 5 | 6 | ##Usage 7 | 8 | ###Installation 9 | ```shell 10 | $ bower install angular-bootstrap-duallistbox 11 | ``` 12 | 13 | This will install AngularJS, jQuery, and the original bootstrap-duallistbox. 14 | 15 | ###Directive 16 | The directive has to be applied to an already existing `select` element with the `multiple` attribute: 17 | 18 | ```html 19 | 25 | ``` 26 | 27 | Set the `select` attributes as you would normally do for a regular multiple list, and add `bs-duallistbox` to enable the plugin. 28 | 29 | ####Options 30 | 31 | The available options for the dual listbox are: 32 | 33 | - `bootstrap2`, if `true` enables compatibility with Bootstrap 2, otherwise it defaults to Bootstrap 3. 34 | - `postfix`, the added selects will have the same name as the original one, concatenated with this string and `1` 35 | (for the non selected list, e.g. `element_helper1`) or `2` (for the selected list, e.g. `element_helper2`). 36 | - `select-min-height`, the minimum height for the dual listbox. 37 | - `filter`, if `true` it enables filtering the dual listbox, otherwise it defaults to `false`. 38 | - `filter-clear`, the text of the "Clear filter" `button`s. 39 | - `filter-placeholder`, the placeholder text for the filter `input`s. 40 | - `filter-values`, if `true` enables filtering on values too. 41 | - `filter-non-selected`, the variable that will be bound to the filter `input` of the non-selected options. 42 | - `filter-selected`, the variable that will be bound to the filter `input` of the selected options. 43 | - `move-on-select`, defaults to `true`, determines whether to move options upon selection. 44 | - `preserve-selection`, can be 45 | - `'all'`, for selecting both moved elements and the already selected ones in the target list 46 | - `'moved'`, for selecting moved elements only 47 | - `false`, the default not to preserve anything 48 | - `move-selected-label`, is the label for the "Move Selected" button, defaults to `'Move selected'`. 49 | - `move-all-label`, is the label for the "Move All" button, defaults to `'Move all'`. 50 | - `remove-selected-label`, is the label for the "Remove Selected" button, defaults to `'Remove selected'`. 51 | - `remove-all-label`, is the label for the "Remove All" button, defaults to `'Remove all'`. 52 | - `selected-list-label`, can be a `string` specifying the name of the selected list., defaults to `false`. 53 | - `non-selected-list-label`, can be a `string` specifying the name of the non selected list., defaults to `false`. 54 | - `info-all`, defaults to `'Showing all {0}'`, determines which `string` format to use when all options are visible. 55 | Set this to `false` to hide this information. Remember to insert the `{0}` placeholder. 56 | - `info-filtered`, defaults to `'Filtered {0} from {1}'`, determines which 57 | element format to use when some element is filtered. Remember to insert the `{0}` and `{1} `placeholders. 58 | - `info-empty`, defaults to `'Empty list'`, determines the `string` to use when there are no options in the list. 59 | 60 | ###Example 61 | The `example` folder shows a simple working demo of the dual list box. 62 | 63 | ##Development 64 | 65 | ###Test and build 66 | 67 | To build the directive yourself you need to have NodeJS. Then do the following: 68 | 69 | ```shell 70 | $ npm install -g grunt-cli bower karma 71 | $ npm install 72 | $ bower install 73 | $ grunt 74 | ``` 75 | 76 | ###Contribute 77 | 78 | To contribute, please follow the generic [AngularJS Contributing Guidelines](https://github.com/angular/angular.js/blob/master/CONTRIBUTING.md), with the only exception to send the PR to the `develop` branch instead of `master`. 79 | 80 | ##Author 81 | 82 | Francesco Pontillo () 83 | 84 | ##License 85 | 86 | ``` 87 | Copyright 2014 Francesco Pontillo 88 | 89 | Licensed under the Apache License, Version 2.0 (the "License"); 90 | you may not use this file except in compliance with the License. 91 | You may obtain a copy of the License at 92 | 93 | http://www.apache.org/licenses/LICENSE-2.0 94 | 95 | Unless required by applicable law or agreed to in writing, software 96 | distributed under the License is distributed on an "AS IS" BASIS, 97 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 98 | See the License for the specific language governing permissions and 99 | limitations under the License. 100 | 101 | ``` 102 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angular-bootstrap-duallistbox", 3 | "version": "0.1.0", 4 | "author": { 5 | "name": "Francesco Pontillo", 6 | "email": "francescopontillo@gmail.com", 7 | "url": "https://github.com/frapontillo" 8 | }, 9 | "homepage": "https://github.com/frapontillo/angular-bootstrap-duallistbox", 10 | "repository": { 11 | "type": "git", 12 | "url": "git@github.com:frapontillo/angular-bootstrap-duallistbox.git" 13 | }, 14 | "licenses": [ 15 | { 16 | "type": "Apache License 2.0", 17 | "url": "http://www.apache.org/licenses/LICENSE-2.0.html" 18 | } 19 | ], 20 | "main": "./dist/angular-bootstrap-duallistbox.js", 21 | "dependencies": { 22 | "angular": "~1.4.0", 23 | "jquery": "~2.0.3", 24 | "bootstrap": ">=2.3.2", 25 | "bootstrap-duallistbox": "3.0.2" 26 | }, 27 | "devDependencies": { 28 | "angular-mocks": "~1.4.0", 29 | "angular-scenario": "~1.4.0" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /common/module.js: -------------------------------------------------------------------------------- 1 | angular.module('frapontillo.bootstrap-duallistbox', []); -------------------------------------------------------------------------------- /dist/angular-bootstrap-duallistbox.js: -------------------------------------------------------------------------------- 1 | /** 2 | * angular-bootstrap-duallistbox 3 | * @version v0.1.0 - 2015-06-13 4 | * @author Francesco Pontillo (francescopontillo@gmail.com) 5 | * @link https://github.com/frapontillo/angular-bootstrap-duallistbox 6 | * @license Apache License 2.0 7 | **/ 8 | 9 | 'use strict'; 10 | // Source: common/module.js 11 | angular.module('frapontillo.bootstrap-duallistbox', []); 12 | // Source: dist/.temp/directives/bsDuallistbox.js 13 | angular.module('frapontillo.bootstrap-duallistbox').directive('bsDuallistbox', [ 14 | '$compile', 15 | '$timeout', 16 | function ($compile, $timeout) { 17 | return { 18 | restrict: 'A', 19 | require: 'ngModel', 20 | link: function link(scope, element, attrs) { 21 | //000011111111110000000000022222222220000000000000000000003333333333000000000000004444444444444440000000005555555555555550000000666666666666666000000000000000777777777700000000000000000008888888888 22 | var NG_OPTIONS_REGEXP = /^\s*([\s\S]+?)(?:\s+as\s+([\s\S]+?))?(?:\s+group\s+by\s+([\s\S]+?))?\s+for\s+(?:([\$\w][\$\w]*)|(?:\(\s*([\$\w][\$\w]*)\s*,\s*([\$\w][\$\w]*)\s*\)))\s+in\s+([\s\S]+?)(?:\s+track\s+by\s+([\s\S]+?))?$/; 23 | // The select collection 24 | var collection = attrs.ngOptions.match(NG_OPTIONS_REGEXP)[7]; 25 | var getBooleanValue = function (attributeValue) { 26 | return attributeValue === true || attributeValue === 'true'; 27 | }; 28 | // The attribute names to $observe with related functions to call 29 | var attributes = { 30 | 'bootstrap2': { 31 | changeFn: 'setBootstrap2Compatible', 32 | transformFn: getBooleanValue 33 | }, 34 | 'postfix': 'setHelperSelectNamePostfix', 35 | 'selectMinHeight': { 36 | changeFn: 'setSelectOrMinimalHeight', 37 | defaultValue: 100 38 | }, 39 | 'filter': { 40 | changeFn: 'setShowFilterInputs', 41 | defaultValue: true, 42 | transformFn: getBooleanValue 43 | }, 44 | 'filterClear': { 45 | changeFn: 'setFilterTextClear', 46 | defaultValue: 'show all' 47 | }, 48 | 'filterPlaceholder': 'setFilterPlaceHolder', 49 | 'filterValues': { 50 | changeFn: 'setFilterOnValues', 51 | transformFn: getBooleanValue 52 | }, 53 | 'moveOnSelect': { 54 | changeFn: 'setMoveOnSelect', 55 | defaultValue: true, 56 | transformFn: getBooleanValue 57 | }, 58 | 'preserveSelection': 'setPreserveSelectionOnMove', 59 | 'moveSelectedLabel': 'setMoveSelectedLabel', 60 | 'moveAllLabel': 'setMoveAllLabel', 61 | 'removeSelectedLabel': 'setRemoveSelectedLabel', 62 | 'removeAllLabel': 'setRemoveAllLabel', 63 | 'selectedListLabel': 'setSelectedListLabel', 64 | 'nonSelectedListLabel': 'setNonSelectedListLabel', 65 | 'infoAll': { 66 | changeFn: 'setInfoText', 67 | defaultValue: 'Showing all {0}' 68 | }, 69 | 'infoFiltered': { 70 | changeFn: 'setInfoTextFiltered', 71 | defaultValue: 'Filtered {0} from {1}' 72 | }, 73 | 'infoEmpty': { 74 | changeFn: 'setInfoTextEmpty', 75 | defaultValue: 'Empty list' 76 | } 77 | }; 78 | // The duallistbox element 79 | var dualListBox; 80 | /** 81 | * Calculates the proper attribute value, given its name. 82 | * The attribute value depends on: 83 | * - the current value 84 | * - the default value, if it exists 85 | * - the transformFn, if it exists 86 | * 87 | * If the current value is undefined, it gets the default value. 88 | * The calculated value is then applied to the transformFn, if it is defined; the result is then returned. 89 | * 90 | * @param attributeName The {String} name of the attribute. 91 | * @returns {Object} representing the value of the attribute. 92 | */ 93 | var getAttributeValueOrDefault = function (attributeName) { 94 | // get the attribute function/object for default and transformation 95 | var attributeFunction = attributes[attributeName]; 96 | // get the current attribute value 97 | var attributeValue = attrs[attributeName]; 98 | // By default, the default value and the transform function are not defined 99 | var defaultValue; 100 | var transformFn; 101 | // If the attributeFunction is an object 102 | if (angular.isObject(attributeFunction)) { 103 | // extract the default value and the transform function 104 | defaultValue = attributeFunction.defaultValue; 105 | transformFn = attributeFunction.transformFn; 106 | } 107 | // If the upcoming value is falsy, get the default 108 | if (!attributeValue) { 109 | attributeValue = defaultValue; 110 | } 111 | // If a transform function is defined, use it to change the value 112 | if (angular.isFunction(transformFn)) { 113 | attributeValue = transformFn(attributeValue); 114 | } 115 | return attributeValue; 116 | }; 117 | /** 118 | * Gets the name of the function of `bootstrap-duallistbox` to be called to effectively 119 | * change the attribute in input. 120 | * 121 | * @param attributeName The name of the attribute to change. 122 | * @returns {String}, name of the `bootstrap-dual-listbox` to be called. 123 | */ 124 | var getAttributeChangeFunction = function (attributeName) { 125 | // get the attribute function/object for default and transformation 126 | var attributeFunction = attributes[attributeName]; 127 | // By default, attributeFunction is a function 128 | var actualFunction = attributeFunction; 129 | // If the attributeFunction is an object 130 | if (angular.isObject(attributeFunction)) { 131 | // extract the actual function name 132 | actualFunction = attributeFunction.changeFn; 133 | } 134 | return actualFunction; 135 | }; 136 | /** 137 | * Listen to model changes. 138 | */ 139 | var listenToModel = function () { 140 | // When ngModel changes, refresh the list 141 | // controller.$formatters.push(refresh); 142 | scope.$watch(attrs.ngModel, function () { 143 | initMaybe(); 144 | refresh(); 145 | }); 146 | // When ngOptions changes, refresh the list 147 | scope.$watch(collection, refresh, true); 148 | // Watch for changes to the filter scope variables 149 | scope.$watch(attrs.filterNonSelected, function () { 150 | refresh(); 151 | }); 152 | scope.$watch(attrs.filterSelected, function () { 153 | refresh(); 154 | }); 155 | // $observe every attribute change 156 | angular.forEach(attributes, function (attributeFunction, attributeName) { 157 | attrs.$observe(attributeName, function () { 158 | var actualFunction = getAttributeChangeFunction(attributeName); 159 | var actualValue = getAttributeValueOrDefault(attributeName); 160 | // Depending on the attribute, call the right function (and always refresh) 161 | element.bootstrapDualListbox(actualFunction, actualValue, true); 162 | }); 163 | }); 164 | }; 165 | /** 166 | * Refresh the Dual List Box using its own API. 167 | */ 168 | var refresh = function () { 169 | // TODO: consider removing $timeout calls 170 | $timeout(function () { 171 | element.bootstrapDualListbox('refresh'); 172 | }); 173 | }; 174 | /** 175 | * If the directive has not been initialized yet, do so. 176 | */ 177 | var initMaybe = function () { 178 | // if it's the first initialization 179 | if (!dualListBox) { 180 | init(); 181 | } 182 | }; 183 | // Delay listbox init 184 | var init = function () { 185 | var defaults = {}; 186 | // for every attribute the directive handles 187 | angular.forEach(attributes, function (attributeFunction, attributeName) { 188 | var actualValue = getAttributeValueOrDefault(attributeName); 189 | defaults[attributeName] = actualValue; 190 | }); 191 | // Init the plugin 192 | dualListBox = element.bootstrapDualListbox({ 193 | bootstrap2Compatible: defaults.bootstrap2, 194 | filterTextClear: defaults.filterClear, 195 | filterPlaceHolder: defaults.filterPlaceholder, 196 | moveSelectedLabel: defaults.moveSelectedLabel, 197 | moveAllLabel: defaults.moveAllLabel, 198 | removeSelectedLabel: defaults.removeSelectedLabel, 199 | removeAllLabel: defaults.removeAllLabel, 200 | moveOnSelect: defaults.moveOnSelect, 201 | preserveSelectionOnMove: defaults.preserveSelection, 202 | selectedListLabel: defaults.selectedListLabel, 203 | nonSelectedListLabel: defaults.nonSelectedListLabel, 204 | helperSelectNamePostfix: defaults.postfix, 205 | selectOrMinimalHeight: defaults.selectMinHeight, 206 | showFilterInputs: defaults.filter, 207 | nonSelectedFilter: '', 208 | selectedFilter: '', 209 | infoText: defaults.infoAll, 210 | infoTextFiltered: defaults.infoFiltered, 211 | infoTextEmpty: defaults.infoEmpty, 212 | filterOnValues: defaults.filterValues 213 | }); 214 | // Inject the ng-model into the filters and re-compile them 215 | var container = element.bootstrapDualListbox('getContainer'); 216 | var filterNonSelectedInput = container.find('.box1 .filter'); 217 | filterNonSelectedInput.attr('ng-model', attrs.filterNonSelected); 218 | $compile(filterNonSelectedInput)(scope); 219 | var filterSelectedInput = container.find('.box2 .filter'); 220 | filterSelectedInput.attr('ng-model', attrs.filterSelected); 221 | $compile(filterSelectedInput)(scope); 222 | }; 223 | // Listen and respond to model changes 224 | listenToModel(); 225 | // On destroy, collect ya garbage 226 | scope.$on('$destroy', function () { 227 | element.bootstrapDualListbox('destroy'); 228 | }); 229 | } 230 | }; 231 | } 232 | ]); -------------------------------------------------------------------------------- /dist/angular-bootstrap-duallistbox.min.js: -------------------------------------------------------------------------------- 1 | /** 2 | * angular-bootstrap-duallistbox 3 | * @version v0.1.0 - 2015-06-13 4 | * @author Francesco Pontillo (francescopontillo@gmail.com) 5 | * @link https://github.com/frapontillo/angular-bootstrap-duallistbox 6 | * @license Apache License 2.0 7 | **/ 8 | 9 | "use strict";angular.module("frapontillo.bootstrap-duallistbox",[]),angular.module("frapontillo.bootstrap-duallistbox").directive("bsDuallistbox",["$compile","$timeout",function(a,b){return{restrict:"A",require:"ngModel",link:function(c,d,e){var f,g=/^\s*([\s\S]+?)(?:\s+as\s+([\s\S]+?))?(?:\s+group\s+by\s+([\s\S]+?))?\s+for\s+(?:([\$\w][\$\w]*)|(?:\(\s*([\$\w][\$\w]*)\s*,\s*([\$\w][\$\w]*)\s*\)))\s+in\s+([\s\S]+?)(?:\s+track\s+by\s+([\s\S]+?))?$/,h=e.ngOptions.match(g)[7],i=function(a){return a===!0||"true"===a},j={bootstrap2:{changeFn:"setBootstrap2Compatible",transformFn:i},postfix:"setHelperSelectNamePostfix",selectMinHeight:{changeFn:"setSelectOrMinimalHeight",defaultValue:100},filter:{changeFn:"setShowFilterInputs",defaultValue:!0,transformFn:i},filterClear:{changeFn:"setFilterTextClear",defaultValue:"show all"},filterPlaceholder:"setFilterPlaceHolder",filterValues:{changeFn:"setFilterOnValues",transformFn:i},moveOnSelect:{changeFn:"setMoveOnSelect",defaultValue:!0,transformFn:i},preserveSelection:"setPreserveSelectionOnMove",moveSelectedLabel:"setMoveSelectedLabel",moveAllLabel:"setMoveAllLabel",removeSelectedLabel:"setRemoveSelectedLabel",removeAllLabel:"setRemoveAllLabel",selectedListLabel:"setSelectedListLabel",nonSelectedListLabel:"setNonSelectedListLabel",infoAll:{changeFn:"setInfoText",defaultValue:"Showing all {0}"},infoFiltered:{changeFn:"setInfoTextFiltered",defaultValue:'Filtered {0} from {1}'},infoEmpty:{changeFn:"setInfoTextEmpty",defaultValue:"Empty list"}},k=function(a){var b,c,d=j[a],f=e[a];return angular.isObject(d)&&(b=d.defaultValue,c=d.transformFn),f||(f=b),angular.isFunction(c)&&(f=c(f)),f},l=function(a){var b=j[a],c=b;return angular.isObject(b)&&(c=b.changeFn),c},m=function(){c.$watch(e.ngModel,function(){o(),n()}),c.$watch(h,n,!0),c.$watch(e.filterNonSelected,function(){n()}),c.$watch(e.filterSelected,function(){n()}),angular.forEach(j,function(a,b){e.$observe(b,function(){var a=l(b),c=k(b);d.bootstrapDualListbox(a,c,!0)})})},n=function(){b(function(){d.bootstrapDualListbox("refresh")})},o=function(){f||p()},p=function(){var b={};angular.forEach(j,function(a,c){var d=k(c);b[c]=d}),f=d.bootstrapDualListbox({bootstrap2Compatible:b.bootstrap2,filterTextClear:b.filterClear,filterPlaceHolder:b.filterPlaceholder,moveSelectedLabel:b.moveSelectedLabel,moveAllLabel:b.moveAllLabel,removeSelectedLabel:b.removeSelectedLabel,removeAllLabel:b.removeAllLabel,moveOnSelect:b.moveOnSelect,preserveSelectionOnMove:b.preserveSelection,selectedListLabel:b.selectedListLabel,nonSelectedListLabel:b.nonSelectedListLabel,helperSelectNamePostfix:b.postfix,selectOrMinimalHeight:b.selectMinHeight,showFilterInputs:b.filter,nonSelectedFilter:"",selectedFilter:"",infoText:b.infoAll,infoTextFiltered:b.infoFiltered,infoTextEmpty:b.infoEmpty,filterOnValues:b.filterValues});var g=d.bootstrapDualListbox("getContainer"),h=g.find(".box1 .filter");h.attr("ng-model",e.filterNonSelected),a(h)(c);var i=g.find(".box2 .filter");i.attr("ng-model",e.filterSelected),a(i)(c)};m(),c.$on("$destroy",function(){d.bootstrapDualListbox("destroy")})}}}]); -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | AngularJS Bootstrap Duallistbox example 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 22 | 23 |
24 | 56 |
57 | 58 | 59 |
60 |
61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | -------------------------------------------------------------------------------- /example/scripts/app.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | angular.module('bsDuallistboxApp', ['frapontillo.bootstrap-duallistbox']); 4 | -------------------------------------------------------------------------------- /example/scripts/controllers/main.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | angular.module('bsDuallistboxApp') 4 | .controller('MainCtrl', function ($scope) { 5 | var lastIndex = 3; 6 | $scope.list = []; 7 | 8 | var updateList = function() { 9 | $scope.list.push({ 10 | 'id': '_1', 11 | 'text': 'one' 12 | },{ 13 | 'id': '_2', 14 | 'text': 'two' 15 | },{ 16 | 'id': '_3', 17 | 'text': 'three' 18 | },{ 19 | 'id': '_4', 20 | 'text': 'four' 21 | }); 22 | }; 23 | 24 | $scope.reset = function() { 25 | $scope.model = []; 26 | }; 27 | 28 | $scope.add = function() { 29 | lastIndex++; 30 | updateList(); 31 | }; 32 | 33 | $scope.settings = { 34 | bootstrap2: false, 35 | filterClear: 'Show all!', 36 | filterPlaceHolder: 'Filter!', 37 | moveSelectedLabel: 'Move selected only', 38 | moveAllLabel: 'Move all!', 39 | removeSelectedLabel: 'Remove selected only', 40 | removeAllLabel: 'Remove all!', 41 | moveOnSelect: true, 42 | preserveSelection: 'moved', 43 | selectedListLabel: 'The selected', 44 | nonSelectedListLabel: 'The unselected', 45 | postfix: '_helperz', 46 | selectMinHeight: 130, 47 | filter: true, 48 | filterNonSelected: '1', 49 | filterSelected: '4', 50 | infoAll: 'Showing all {0}!', 51 | infoFiltered: 'Filtered {0} from {1}!', 52 | infoEmpty: 'Empty list!', 53 | filterValues: true 54 | }; 55 | 56 | updateList(); 57 | 58 | }); 59 | -------------------------------------------------------------------------------- /example/styles/main.css: -------------------------------------------------------------------------------- 1 | body { 2 | background: #fafafa; 3 | font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; 4 | color: #333; 5 | } 6 | 7 | .hero-unit { 8 | margin: 50px auto 0 auto; 9 | width: 300px; 10 | font-size: 18px; 11 | font-weight: 200; 12 | line-height: 30px; 13 | background-color: #eee; 14 | border-radius: 6px; 15 | padding: 60px; 16 | } 17 | 18 | .hero-unit h1 { 19 | font-size: 60px; 20 | line-height: 1; 21 | letter-spacing: -1px; 22 | } 23 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration 2 | // Generated on Tue Mar 11 2014 19:55:38 GMT+0100 (ora solare Europa occidentale) 3 | 4 | module.exports = function(config) { 5 | config.set({ 6 | 7 | // base path, that will be used to resolve files and exclude 8 | basePath: '', 9 | 10 | 11 | // frameworks to use 12 | frameworks: ['jasmine'], 13 | 14 | 15 | // list of files / patterns to load in the browser 16 | files: [ 17 | 'bower_components/jquery/jquery.js', 18 | 'bower_components/angular/angular.js', 19 | 'bower_components/angular-mocks/angular-mocks.js', 20 | 'bower_components/bootstrap-duallistbox/src/jquery.bootstrap-duallistbox.js', 21 | 'common/module.js', 22 | 'src/**/*.js', 23 | 'test/**/*.js' 24 | ], 25 | 26 | 27 | // list of files to exclude 28 | exclude: [ 29 | 30 | ], 31 | 32 | 33 | // test results reporter to use 34 | // possible values: 'dots', 'progress', 'junit', 'growl', 'coverage' 35 | reporters: ['progress'], 36 | 37 | 38 | // web server port 39 | port: 9876, 40 | 41 | 42 | // enable / disable colors in the output (reporters and logs) 43 | colors: true, 44 | 45 | 46 | // level of logging 47 | // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG 48 | logLevel: config.LOG_INFO, 49 | 50 | 51 | // enable / disable watching file and executing tests whenever any file changes 52 | autoWatch: true, 53 | 54 | 55 | // Start these browsers, currently available: 56 | // - Chrome 57 | // - ChromeCanary 58 | // - Firefox 59 | // - Opera (has to be installed with `npm install karma-opera-launcher`) 60 | // - Safari (only Mac; has to be installed with `npm install karma-safari-launcher`) 61 | // - PhantomJS 62 | // - IE (only Windows; has to be installed with `npm install karma-ie-launcher`) 63 | browsers: ['PhantomJS'], 64 | 65 | 66 | // If browser does not capture in given timeout [ms], kill it 67 | captureTimeout: 60000, 68 | 69 | 70 | // Continuous Integration mode 71 | // if true, it capture browsers, run tests and exit 72 | singleRun: true 73 | }); 74 | }; 75 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angular-bootstrap-duallistbox", 3 | "version": "0.1.0", 4 | "author": { 5 | "name": "Francesco Pontillo", 6 | "email": "francescopontillo@gmail.com", 7 | "url": "https://github.com/frapontillo" 8 | }, 9 | "homepage": "https://github.com/frapontillo/angular-bootstrap-duallistbox", 10 | "repository": { 11 | "type": "git", 12 | "url": "git@github.com:frapontillo/angular-bootstrap-duallistbox.git" 13 | }, 14 | "licenses": [ 15 | { 16 | "type": "Apache License 2.0", 17 | "url": "http://www.apache.org/licenses/LICENSE-2.0.html" 18 | } 19 | ], 20 | "dependencies": {}, 21 | "devDependencies": { 22 | "grunt": "~0.4.3", 23 | "grunt-contrib-concat": "~0.3.0", 24 | "grunt-contrib-uglify": "~0.4.0", 25 | "grunt-contrib-jshint": "~0.8.0", 26 | "grunt-contrib-clean": "~0.5.0", 27 | "grunt-karma": "~0.8.0", 28 | "matchdep": "~0.3.0", 29 | "grunt-ngmin": "0.0.3", 30 | "karma": "~0.12.0", 31 | "karma-jasmine": "~0.2.0", 32 | "karma-chrome-launcher": "^0.1.2", 33 | "karma-script-launcher": "^0.1.0", 34 | "karma-firefox-launcher": "^0.1.3", 35 | "karma-html2js-preprocessor": "^0.1.0", 36 | "requirejs": "^2.1.11", 37 | "karma-requirejs": "^0.2.1", 38 | "karma-phantomjs-launcher": "^0.1.2" 39 | }, 40 | "engines": { 41 | "node": ">=0.10.0" 42 | }, 43 | "scripts": { 44 | "test": "grunt test" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/directives/bsDuallistbox.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | angular.module('frapontillo.bootstrap-duallistbox') 4 | .directive('bsDuallistbox', function ($compile, $timeout) { 5 | return { 6 | restrict: 'A', 7 | require: 'ngModel', 8 | link: function link(scope, element, attrs) { 9 | //000011111111110000000000022222222220000000000000000000003333333333000000000000004444444444444440000000005555555555555550000000666666666666666000000000000000777777777700000000000000000008888888888 10 | var NG_OPTIONS_REGEXP = /^\s*([\s\S]+?)(?:\s+as\s+([\s\S]+?))?(?:\s+group\s+by\s+([\s\S]+?))?\s+for\s+(?:([\$\w][\$\w]*)|(?:\(\s*([\$\w][\$\w]*)\s*,\s*([\$\w][\$\w]*)\s*\)))\s+in\s+([\s\S]+?)(?:\s+track\s+by\s+([\s\S]+?))?$/; 11 | 12 | // The select collection 13 | var collection = attrs.ngOptions.match(NG_OPTIONS_REGEXP)[7]; 14 | 15 | var getBooleanValue = function(attributeValue) { 16 | return (attributeValue === true || attributeValue === 'true'); 17 | }; 18 | 19 | // The attribute names to $observe with related functions to call 20 | var attributes = { 21 | 'bootstrap2': {changeFn: 'setBootstrap2Compatible', transformFn: getBooleanValue}, 22 | 'postfix': 'setHelperSelectNamePostfix', 23 | 'selectMinHeight': {changeFn: 'setSelectOrMinimalHeight', defaultValue: 100}, 24 | 25 | 'filter': {changeFn: 'setShowFilterInputs', defaultValue: true, transformFn: getBooleanValue}, 26 | 'filterClear': {changeFn: 'setFilterTextClear', defaultValue: 'show all'}, 27 | 'filterPlaceholder': 'setFilterPlaceHolder', 28 | 'filterValues': {changeFn: 'setFilterOnValues', transformFn: getBooleanValue}, 29 | 30 | 'moveOnSelect': {changeFn: 'setMoveOnSelect', defaultValue: true, transformFn: getBooleanValue}, 31 | 'preserveSelection': 'setPreserveSelectionOnMove', 32 | 33 | 'moveSelectedLabel': 'setMoveSelectedLabel', 34 | 'moveAllLabel': 'setMoveAllLabel', 35 | 'removeSelectedLabel': 'setRemoveSelectedLabel', 36 | 'removeAllLabel': 'setRemoveAllLabel', 37 | 'selectedListLabel' : 'setSelectedListLabel', 38 | 'nonSelectedListLabel' : 'setNonSelectedListLabel', 39 | 40 | 'infoAll': {changeFn: 'setInfoText', defaultValue: 'Showing all {0}'}, 41 | 'infoFiltered': {changeFn: 'setInfoTextFiltered', defaultValue: 'Filtered {0} from {1}'}, 42 | 'infoEmpty': {changeFn: 'setInfoTextEmpty', defaultValue: 'Empty list'} 43 | }; 44 | 45 | // The duallistbox element 46 | var dualListBox; 47 | 48 | /** 49 | * Calculates the proper attribute value, given its name. 50 | * The attribute value depends on: 51 | * - the current value 52 | * - the default value, if it exists 53 | * - the transformFn, if it exists 54 | * 55 | * If the current value is undefined, it gets the default value. 56 | * The calculated value is then applied to the transformFn, if it is defined; the result is then returned. 57 | * 58 | * @param attributeName The {String} name of the attribute. 59 | * @returns {Object} representing the value of the attribute. 60 | */ 61 | var getAttributeValueOrDefault = function(attributeName) { 62 | // get the attribute function/object for default and transformation 63 | var attributeFunction = attributes[attributeName]; 64 | // get the current attribute value 65 | var attributeValue = attrs[attributeName]; 66 | 67 | // By default, the default value and the transform function are not defined 68 | var defaultValue; 69 | var transformFn; 70 | // If the attributeFunction is an object 71 | if (angular.isObject(attributeFunction)) { 72 | // extract the default value and the transform function 73 | defaultValue = attributeFunction.defaultValue; 74 | transformFn = attributeFunction.transformFn; 75 | } 76 | // If the upcoming value is falsy, get the default 77 | if (!attributeValue) { 78 | attributeValue = defaultValue; 79 | } 80 | // If a transform function is defined, use it to change the value 81 | if (angular.isFunction(transformFn)) { 82 | attributeValue = transformFn(attributeValue); 83 | } 84 | 85 | return attributeValue; 86 | }; 87 | 88 | /** 89 | * Gets the name of the function of `bootstrap-duallistbox` to be called to effectively 90 | * change the attribute in input. 91 | * 92 | * @param attributeName The name of the attribute to change. 93 | * @returns {String}, name of the `bootstrap-dual-listbox` to be called. 94 | */ 95 | var getAttributeChangeFunction = function(attributeName) { 96 | // get the attribute function/object for default and transformation 97 | var attributeFunction = attributes[attributeName]; 98 | 99 | // By default, attributeFunction is a function 100 | var actualFunction = attributeFunction; 101 | // If the attributeFunction is an object 102 | if (angular.isObject(attributeFunction)) { 103 | // extract the actual function name 104 | actualFunction = attributeFunction.changeFn; 105 | } 106 | 107 | return actualFunction; 108 | }; 109 | 110 | /** 111 | * Listen to model changes. 112 | */ 113 | var listenToModel = function () { 114 | // When ngModel changes, refresh the list 115 | // controller.$formatters.push(refresh); 116 | scope.$watch(attrs.ngModel, function() { 117 | initMaybe(); 118 | refresh(); 119 | }); 120 | 121 | // When ngOptions changes, refresh the list 122 | scope.$watch(collection, refresh, true); 123 | 124 | // Watch for changes to the filter scope variables 125 | scope.$watch(attrs.filterNonSelected, function() { 126 | refresh(); 127 | }); 128 | scope.$watch(attrs.filterSelected, function() { 129 | refresh(); 130 | }); 131 | 132 | // $observe every attribute change 133 | angular.forEach(attributes, function(attributeFunction, attributeName) { 134 | attrs.$observe(attributeName, function() { 135 | var actualFunction = getAttributeChangeFunction(attributeName); 136 | var actualValue = getAttributeValueOrDefault(attributeName); 137 | // Depending on the attribute, call the right function (and always refresh) 138 | element.bootstrapDualListbox(actualFunction, actualValue, true); 139 | }); 140 | }); 141 | }; 142 | 143 | /** 144 | * Refresh the Dual List Box using its own API. 145 | */ 146 | var refresh = function() { 147 | // TODO: consider removing $timeout calls 148 | $timeout(function () { 149 | element.bootstrapDualListbox('refresh'); 150 | }); 151 | }; 152 | 153 | /** 154 | * If the directive has not been initialized yet, do so. 155 | */ 156 | var initMaybe = function() { 157 | // if it's the first initialization 158 | if (!dualListBox) { 159 | init(); 160 | } 161 | }; 162 | 163 | // Delay listbox init 164 | var init = function() { 165 | var defaults = {}; 166 | // for every attribute the directive handles 167 | angular.forEach(attributes, function(attributeFunction, attributeName) { 168 | var actualValue = getAttributeValueOrDefault(attributeName); 169 | defaults[attributeName] = actualValue; 170 | }); 171 | 172 | // Init the plugin 173 | dualListBox = element.bootstrapDualListbox({ 174 | bootstrap2Compatible: defaults.bootstrap2, 175 | filterTextClear: defaults.filterClear, 176 | filterPlaceHolder: defaults.filterPlaceholder, 177 | moveSelectedLabel: defaults.moveSelectedLabel, 178 | moveAllLabel: defaults.moveAllLabel, 179 | removeSelectedLabel: defaults.removeSelectedLabel, 180 | removeAllLabel: defaults.removeAllLabel, 181 | moveOnSelect: defaults.moveOnSelect, 182 | preserveSelectionOnMove: defaults.preserveSelection, 183 | selectedListLabel: defaults.selectedListLabel, 184 | nonSelectedListLabel: defaults.nonSelectedListLabel, 185 | helperSelectNamePostfix: defaults.postfix, 186 | selectOrMinimalHeight: defaults.selectMinHeight, 187 | showFilterInputs: defaults.filter, 188 | nonSelectedFilter: '', 189 | selectedFilter: '', 190 | infoText: defaults.infoAll, 191 | infoTextFiltered: defaults.infoFiltered, 192 | infoTextEmpty: defaults.infoEmpty, 193 | filterOnValues: defaults.filterValues // TODO Test 194 | }); 195 | 196 | // Inject the ng-model into the filters and re-compile them 197 | var container = element.bootstrapDualListbox('getContainer'); 198 | var filterNonSelectedInput = container.find('.box1 .filter'); 199 | filterNonSelectedInput.attr('ng-model', attrs.filterNonSelected); 200 | $compile(filterNonSelectedInput)(scope); 201 | var filterSelectedInput = container.find('.box2 .filter'); 202 | filterSelectedInput.attr('ng-model', attrs.filterSelected); 203 | $compile(filterSelectedInput)(scope); 204 | }; 205 | 206 | // Listen and respond to model changes 207 | listenToModel(); 208 | 209 | // On destroy, collect ya garbage 210 | scope.$on('$destroy', function () { 211 | element.bootstrapDualListbox('destroy'); 212 | }); 213 | } 214 | }; 215 | }); 216 | -------------------------------------------------------------------------------- /test/.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "node": true, 3 | "browser": true, 4 | "esnext": true, 5 | "bitwise": true, 6 | "camelcase": true, 7 | "curly": true, 8 | "eqeqeq": true, 9 | "immed": true, 10 | "indent": 2, 11 | "latedef": true, 12 | "newcap": true, 13 | "noarg": true, 14 | "quotmark": "single", 15 | "regexp": true, 16 | "undef": true, 17 | "unused": true, 18 | "strict": true, 19 | "trailing": true, 20 | "smarttabs": true, 21 | "globals": { 22 | "after": false, 23 | "afterEach": false, 24 | "angular": false, 25 | "before": false, 26 | "beforeEach": false, 27 | "browser": false, 28 | "describe": false, 29 | "expect": false, 30 | "inject": false, 31 | "it": false, 32 | "spyOn": false 33 | } 34 | } 35 | 36 | -------------------------------------------------------------------------------- /test/runner.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | End2end Test Runner 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /test/spec/directives/bsDuallistboxSpec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | describe('Directive: bsDuallistbox', function () { 4 | var scope, $sandbox, $compile, $timeout; 5 | 6 | beforeEach(module('frapontillo.bootstrap-duallistbox')); 7 | 8 | /* jshint camelcase: false */ 9 | beforeEach(inject(function ($injector, $rootScope, _$compile_, _$timeout_) { 10 | scope = $rootScope; 11 | $compile = _$compile_; 12 | $timeout = _$timeout_; 13 | $sandbox = angular.element('
').appendTo(angular.element.find('body')); 14 | })); 15 | /* jshint camelcase: true */ 16 | 17 | afterEach(function () { 18 | $sandbox.remove(); 19 | scope.$destroy(); 20 | }); 21 | 22 | var templates = { 23 | 'default': { 24 | scope: { 25 | list: ['one', 'two', 'three', 'four', 'five', 'forty-two'], 26 | model: [] 27 | }, 28 | element: 'ng-options="element for element in list" ' 29 | }, 30 | 'bootstrap2': { 31 | scope: { 32 | bootstrap2: false 33 | }, 34 | element: 'bootstrap2="{{ bootstrap2 }}" ' + 35 | 'ng-options="element for element in list" ' 36 | }, 37 | 'filter': { 38 | scope: { 39 | filter: { }, 40 | info: { }, 41 | list: [{ 42 | 'id': '_1', 43 | 'text': 'one' 44 | },{ 45 | 'id': '_2', 46 | 'text': 'two' 47 | },{ 48 | 'id': '_3', 49 | 'text': 'three' 50 | },{ 51 | 'id': '_4', 52 | 'text': 'four' 53 | }], 54 | model: [{ 55 | 'id': '_2', 56 | 'text': 'two' 57 | }, { 58 | 'id': '_4', 59 | 'text': 'four' 60 | }] 61 | }, 62 | element: '' + 63 | 'ng-options="element as element.text for element in list track by element.id" ' + 64 | 'filter-placeholder="{{ filter.filterPlaceholder }}" ' + 65 | 'show-filter-inputs="{{ filter.showFilterInputs }}" ' + 66 | 'filter-non-selected="filter.filterNonSelected" ' + 67 | 'filter-selected="filter.filterSelected" ' + 68 | 'filter-clear="{{ filter.filterClear }}" ' + 69 | 'filter-values="{{ filter.filterValues }}" ' + 70 | 'info-all="{{ info.text }}" ' + 71 | 'info-filtered="{{ info.textFiltered }}" ' 72 | }, 73 | 'empty': { 74 | element: '' + 75 | 'ng-options="element for element in list" ' + 76 | 'info-empty="{{ info.textEmpty }}" ' 77 | }, 78 | 'buttons': { 79 | scope: { 80 | labels: { 81 | } 82 | }, 83 | element: '' + 84 | 'ng-options="element for element in list" ' + 85 | 'move-selected-label="{{ labels.moveSelected }}" ' + 86 | 'move-all-label="{{ labels.moveAll }}" ' + 87 | 'remove-selected-label="{{ labels.removeSelected }}" ' + 88 | 'remove-all-label="{{ labels.removeAll }}" ' 89 | }, 90 | 'lists': { 91 | scope: { 92 | labels: { 93 | } 94 | }, 95 | element: '' + 96 | 'ng-options="element for element in list" ' + 97 | 'non-selected-list-label="{{ labels.nonSelected }}" ' + 98 | 'selected-list-label="{{ labels.selected }}" ' + 99 | 'postfix="{{ postfix }}" ' 100 | }, 101 | 'move': { 102 | element: '' + 103 | 'ng-options="element for element in list" ' + 104 | 'move-on-select="{{ moveOnSelect }}" ' 105 | }, 106 | 'preserve': { 107 | element: '' + 108 | 'ng-options="element for element in list" ' + 109 | 'move-on-select="{{ moveOnSelect }}" ' + 110 | 'preserve-selection="{{ preserveSelection }}" ', 111 | scope: { 112 | model: ['one','two','three'], 113 | moveOnSelect: false, 114 | preserveSelection: false 115 | } 116 | }, 117 | 'height': { 118 | element: '' + 119 | 'ng-options="element for element in list" ' + 120 | 'select-min-height="{{ minimalHeight }}" ' 121 | } 122 | }; 123 | 124 | var basicElement = [ 125 | '' 129 | ]; 130 | 131 | /** 132 | * Build an element string. 133 | * @param template The template element to be used 134 | * @returns {string} The HTML element as a string 135 | */ 136 | function buildElement(template) { 137 | var elementContent = template.element; 138 | var realElement; 139 | if (template.override) { 140 | realElement = elementContent; 141 | } else { 142 | realElement = basicElement[0] + elementContent + basicElement[1]; 143 | } 144 | return realElement; 145 | } 146 | 147 | /** 148 | * Compile a given template object as a `select`. 149 | * @param template The template object 150 | * @returns {*} compiled angular element 151 | */ 152 | function compileDirective(template) { 153 | // If the template is undefined, default to 'default' 154 | var compileTemplate = template || 'default'; 155 | template = templates[compileTemplate]; 156 | // Extend the current scope with the default one and then the selected one 157 | angular.extend(scope, angular.copy(templates['default'].scope), angular.copy(template.scope)); 158 | var content = buildElement(template); 159 | var $element = angular.element(content).appendTo($sandbox); 160 | $compile($element)(scope); 161 | scope.$apply(); 162 | $timeout.flush(); 163 | $element = $sandbox.find('*:first-child'); 164 | return $element; 165 | } 166 | 167 | /** 168 | * Gets the text of an {HTMLOption} element. 169 | * @param option The {HTMLOption} element. 170 | * @returns {String} content of the element. 171 | */ 172 | function getText(option) { 173 | return option.text; 174 | } 175 | 176 | describe('default behaviour', function() { 177 | var element; 178 | beforeEach(function() { 179 | element = compileDirective('default'); 180 | }); 181 | 182 | // Test the duallistbox creation and defaults 183 | it('should create a duallistbox', inject(function () { 184 | expect(element.hasClass('bootstrap-duallistbox-container')).toBe(true); 185 | expect(element.find('.box1')).toBeDefined(); 186 | expect(element.find('.box2')).toBeDefined(); 187 | expect($sandbox.find('> select').css('display')).toBe('none'); 188 | })); 189 | 190 | // Test the unselected and selected list after bootstrap 191 | it('should properly initialize both list', inject(function () { 192 | expect(element.find('#bootstrap-duallistbox-nonselected-list_mySelect').length).not.toBe(0); 193 | expect(element.find('#bootstrap-duallistbox-nonselected-list_mySelect > option').length).toBe(6); 194 | expect(element.find('#bootstrap-duallistbox-selected-list_mySelect').length).not.toBe(0); 195 | expect(element.find('#bootstrap-duallistbox-selected-list_mySelect > option').length).toBe(0); 196 | })); 197 | 198 | // A model change should update both lists accordingly 199 | it('should update lists according to the model change', inject(function () { 200 | scope.model = ['one', 'two']; 201 | scope.$apply(); 202 | $timeout.flush(); 203 | 204 | var nonSelectedOpts = element.find('#bootstrap-duallistbox-nonselected-list_mySelect > option'); 205 | expect(nonSelectedOpts.length).toBe(4); 206 | var nonSelectedTexts = Array.prototype.map.call(nonSelectedOpts, getText); 207 | expect(nonSelectedTexts).toContain('three'); 208 | expect(nonSelectedTexts).toContain('four'); 209 | expect(nonSelectedTexts).toContain('five'); 210 | expect(nonSelectedTexts).toContain('forty-two'); 211 | 212 | var selectedOpts = element.find('#bootstrap-duallistbox-selected-list_mySelect > option'); 213 | expect(selectedOpts.length).toBe(2); 214 | var selectedTexts = Array.prototype.map.call(selectedOpts, getText); 215 | expect(selectedTexts).toContain('one'); 216 | expect(selectedTexts).toContain('two'); 217 | })); 218 | }); 219 | 220 | describe('appearance', function() { 221 | // Test the bootstrap2 compatibility 222 | it('should be compatible with bootstrap version 2', inject(function () { 223 | var element = compileDirective('bootstrap2'); 224 | expect(element.hasClass('row')).toBeTruthy(); 225 | expect(element.hasClass('row-fluid bs2compatible')).toBeFalsy(); 226 | scope.bootstrap2 = true; 227 | scope.$apply(); 228 | expect(element.hasClass('row')).toBeFalsy(); 229 | expect(element.hasClass('row-fluid bs2compatible')).toBeTruthy(); 230 | })); 231 | 232 | // Test the height of the selects 233 | it('should be more than about 100px of height', inject(function () { 234 | var element = compileDirective('height'); 235 | var h1 = element.find('.box1 select').height(); 236 | var h2 = element.find('.box2 select').height(); 237 | expect(h1).toBeGreaterThan(95); 238 | expect(h2).toBeGreaterThan(95); 239 | })); 240 | 241 | // Test the height of the selects 242 | it('should be more than about 200px of height', inject(function () { 243 | var element = compileDirective('height'); 244 | scope.minimalHeight = 200; 245 | scope.$apply(); 246 | var h1 = element.find('.box1 select').height(); 247 | var h2 = element.find('.box2 select').height(); 248 | expect(h1).toBeGreaterThan(195); 249 | expect(h2).toBeGreaterThan(195); 250 | })); 251 | }); 252 | 253 | describe('filter', function() { 254 | var element; 255 | beforeEach(function() { 256 | element = compileDirective('filter'); 257 | }); 258 | 259 | // Test the filter defaults 260 | it('should setup the default filter parameters (placeholder, visibility)', inject(function () { 261 | element.find('.filter').each(function (position, filterInput) { 262 | var $input = angular.element(filterInput); 263 | expect($input.attr('placeholder')).toBe('Filter'); 264 | expect($input.is(':visible')).toBeTruthy(); 265 | // filter inputs should be empty 266 | expect($input.val()).toBe(''); 267 | }); 268 | // both selects have elements 269 | expect(element.find('#bootstrap-duallistbox-nonselected-list_mySelect > option').length).toBe(2); 270 | expect(element.find('#bootstrap-duallistbox-selected-list_mySelect > option').length).toBe(2); 271 | })); 272 | 273 | // Test the filtering system 274 | it('should filter the selects when the model changes', inject(function () { 275 | // filter both lists 276 | scope.filter.filterNonSelected = 't'; 277 | scope.filter.filterSelected = 'f'; 278 | scope.$apply(); 279 | $timeout.flush(); 280 | // the non selected list must contain only 'three' and 'forty-two' 281 | var nonSelectedOpts = element.find('#bootstrap-duallistbox-nonselected-list_mySelect > option'); 282 | expect(nonSelectedOpts.length).toBe(1); 283 | expect(Array.prototype.map.call(nonSelectedOpts, getText)).toContain('three'); 284 | // the selected list must contain only 'four' 285 | var selectedOpts = element.find('#bootstrap-duallistbox-selected-list_mySelect > option'); 286 | expect(selectedOpts.length).toBe(1); 287 | expect(Array.prototype.map.call(selectedOpts, getText)).toContain('four'); 288 | })); 289 | 290 | // Test the values filtering system 291 | it('should filter the selects with values when the model changes', inject(function () { 292 | // filter both lists 293 | scope.filter.filterValues = true; 294 | scope.filter.filterNonSelected = '1'; 295 | scope.filter.filterSelected = '4'; 296 | scope.$apply(); 297 | $timeout.flush(); 298 | // the non selected list must contain only 'one' and 'four' 299 | var nonSelectedOpts = element.find('#bootstrap-duallistbox-nonselected-list_mySelect > option'); 300 | expect(nonSelectedOpts.length).toBe(1); 301 | expect(Array.prototype.map.call(nonSelectedOpts, getText)).toContain('one'); 302 | // the selected list must contain only 'four' 303 | var selectedOpts = element.find('#bootstrap-duallistbox-selected-list_mySelect > option'); 304 | expect(selectedOpts.length).toBe(1); 305 | expect(Array.prototype.map.call(selectedOpts, getText)).toContain('four'); 306 | })); 307 | 308 | // Test the filtering system by view 309 | it('should filter the selects when the view changes', inject(function () { 310 | // filter both lists 311 | element.find('.box1 .filter').val('t').change(); 312 | element.find('.box2 .filter').val('f').change(); 313 | // the non selected list must contain only 'three' and 'forty-two' 314 | var nonSelectedOpts = element.find('#bootstrap-duallistbox-nonselected-list_mySelect > option'); 315 | expect(nonSelectedOpts.length).toBe(1); 316 | expect(Array.prototype.map.call(nonSelectedOpts, getText)).toContain('three'); 317 | // the selected list must contain only 'four' 318 | var selectedOpts = element.find('#bootstrap-duallistbox-selected-list_mySelect > option'); 319 | expect(selectedOpts.length).toBe(1); 320 | expect(Array.prototype.map.call(selectedOpts, getText)).toContain('four'); 321 | })); 322 | 323 | // Test the values filtering system by view 324 | it('should filter the selects with values when the view changes', inject(function () { 325 | // filter both lists 326 | scope.filter.filterValues = true; 327 | element.find('.box1 .filter').val('1').change(); 328 | element.find('.box2 .filter').val('4').change(); 329 | scope.$apply(); 330 | // the non selected list must contain only 'one' and 'four' 331 | var nonSelectedOpts = element.find('#bootstrap-duallistbox-nonselected-list_mySelect > option'); 332 | expect(nonSelectedOpts.length).toBe(1); 333 | expect(Array.prototype.map.call(nonSelectedOpts, getText)).toContain('one'); 334 | // the selected list must contain only 'four' 335 | var selectedOpts = element.find('#bootstrap-duallistbox-selected-list_mySelect > option'); 336 | expect(selectedOpts.length).toBe(1); 337 | expect(Array.prototype.map.call(selectedOpts, getText)).toContain('four'); 338 | })); 339 | }); 340 | 341 | describe('info text', function() { 342 | // Test the info text for unfiltered lists 343 | it('should change the info text for unfiltered lists', inject(function () { 344 | var element = compileDirective('filter'); 345 | // filter inputs should be empty 346 | expect(element.find('.box1 .info').html()).toBe('Showing all 2'); 347 | expect(element.find('.box2 .info').html()).toBe('Showing all 2'); 348 | scope.info.text = 'All {0}'; 349 | scope.$apply(); 350 | expect(element.find('.box1 .info').html()).toBe('All 2'); 351 | expect(element.find('.box2 .info').html()).toBe('All 2'); 352 | })); 353 | 354 | // Test the info text for filtered lists 355 | it('should change the info text for filtered lists', inject(function () { 356 | var element = compileDirective('filter'); 357 | scope.filter.filterNonSelected = 't'; 358 | scope.filter.filterSelected = 'f'; 359 | scope.$apply(); 360 | $timeout.flush(); 361 | // filter inputs should be empty 362 | expect(element.find('.box1 .info').html()).toBe('Filtered 1 from 2'); 363 | expect(element.find('.box2 .info').html()).toBe('Filtered 1 from 2'); 364 | scope.info.textFiltered = '{0}/{1}'; 365 | scope.$apply(); 366 | expect(element.find('.box1 .info').html()).toBe('1/2'); 367 | expect(element.find('.box2 .info').html()).toBe('1/2'); 368 | })); 369 | 370 | // Test the info text for empty lists 371 | it('should change the info text for empty lists', inject(function () { 372 | var element = compileDirective('empty'); 373 | 374 | // test default behavior 375 | expect(element.find('.box2 .info').html()).toBe('Empty list'); 376 | // move all elements 377 | scope.model = angular.copy(scope.list); 378 | scope.$apply(); 379 | $timeout.flush(); 380 | expect(element.find('.box1 .info').html()).toBe('Empty list'); 381 | 382 | // change the empty text 383 | scope.info = { textEmpty: 'No luck, mate'}; 384 | scope.$apply(); 385 | expect(element.find('.box1 .info').html()).toBe('No luck, mate'); 386 | scope.model = []; 387 | scope.$apply(); 388 | $timeout.flush(); 389 | expect(element.find('.box2 .info').html()).toBe('No luck, mate'); 390 | })); 391 | }); 392 | 393 | describe('clear button', function() { 394 | // Test the clear text button 395 | it('should change the text for the clear button', inject(function () { 396 | var element = compileDirective('filter'); 397 | // filter both lists 398 | scope.filter.filterNonSelected = 't'; 399 | scope.filter.filterSelected = 'f'; 400 | scope.$apply(); 401 | $timeout.flush(); 402 | expect(element.find('.box1 .clear1').html()).toBe('show all'); 403 | expect(element.find('.box2 .clear2').html()).toBe('show all'); 404 | 405 | // change the button text 406 | scope.filter.filterClear = 'Clear filter'; 407 | scope.$apply(); 408 | expect(element.find('.box1 .clear1').html()).toBe('Clear filter'); 409 | expect(element.find('.box2 .clear2').html()).toBe('Clear filter'); 410 | })); 411 | }); 412 | 413 | describe('button title', function() { 414 | var element; 415 | beforeEach(function() { 416 | element = compileDirective('buttons'); 417 | }); 418 | 419 | // Test the default button titles 420 | it('should set the default button titles', inject(function () { 421 | expect(element.find('.box1 .move').attr('title')).toBe('Move selected'); 422 | expect(element.find('.box1 .moveall').attr('title')).toBe('Move all'); 423 | expect(element.find('.box2 .remove').attr('title')).toBe('Remove selected'); 424 | expect(element.find('.box2 .removeall').attr('title')).toBe('Remove all'); 425 | })); 426 | 427 | // Test the change of button titles 428 | it('should change the button titles', inject(function () { 429 | scope.labels.moveSelected = 'Move this'; 430 | scope.labels.moveAll = 'Move them all'; 431 | scope.labels.removeSelected = 'Remove this'; 432 | scope.labels.removeAll = 'Remove them all'; 433 | scope.$apply(); 434 | expect(element.find('.box1 .move').attr('title')).toBe(scope.labels.moveSelected); 435 | expect(element.find('.box1 .moveall').attr('title')).toBe(scope.labels.moveAll); 436 | expect(element.find('.box2 .remove').attr('title')).toBe(scope.labels.removeSelected); 437 | expect(element.find('.box2 .removeall').attr('title')).toBe(scope.labels.removeAll); 438 | })); 439 | }); 440 | 441 | describe('list label', function() { 442 | var element; 443 | beforeEach(function() { 444 | element = compileDirective('lists'); 445 | }); 446 | 447 | // Test the default list labels 448 | it('should set the default list labels', inject(function () { 449 | expect(element.find('.box1 > label').is(':visible')).toBeFalsy(); 450 | expect(element.find('.box1 > label').html()).toBe(''); 451 | expect(element.find('.box2 > label').is(':visible')).toBeFalsy(); 452 | expect(element.find('.box2 > label').html()).toBe(''); 453 | })); 454 | 455 | // Test the change of list labels 456 | it('should change the list labels', inject(function () { 457 | scope.labels.nonSelected = 'The unselected'; 458 | scope.labels.selected = 'The selected'; 459 | scope.$apply(); 460 | expect(element.find('.box1 > label').is(':visible')).toBeTruthy(); 461 | expect(element.find('.box1 > label').html()).toBe(scope.labels.nonSelected); 462 | expect(element.find('.box2 > label').is(':visible')).toBeTruthy(); 463 | expect(element.find('.box2 > label').html()).toBe(scope.labels.selected); 464 | })); 465 | }); 466 | 467 | describe('move and remove', function() { 468 | var element; 469 | beforeEach(function() { 470 | element = compileDirective('move'); 471 | }); 472 | 473 | // Test the selects after the click on options 474 | it('should move and remove elements when they are selected (default)', inject(function () { 475 | // select 'one','two','three' 476 | element.find('.box1 select option').slice(0, 3).prop('selected', true).change(); 477 | expect(scope.model.length).toBe(3); 478 | expect(scope.model).toContain('one'); 479 | expect(scope.model).toContain('two'); 480 | expect(scope.model).toContain('three'); 481 | element.find('.box2 select option').slice(0, 3).prop('selected', true).change(); 482 | expect(scope.model.length).toBe(0); 483 | })); 484 | 485 | // Test the selects after the click on options 486 | it('should not move elements when they are selected', inject(function () { 487 | scope.moveOnSelect = false; 488 | scope.$apply(); 489 | // select 'one','two','three' 490 | element.find('.box1 select option').slice(0, 3).prop('selected', true).change(); 491 | expect(scope.model.length).toBe(0); 492 | })); 493 | 494 | // Test the left select after options are selected and moved 495 | it('should move elements when "move" button is clicked', inject(function () { 496 | scope.moveOnSelect = false; 497 | scope.$apply(); 498 | // select 'one','two','three' 499 | element.find('.box1 select option').slice(0, 3).prop('selected', true).change(); 500 | element.find('.box1 .move').click(); 501 | expect(scope.model.length).toBe(3); 502 | expect(scope.model).toContain('one'); 503 | expect(scope.model).toContain('two'); 504 | expect(scope.model).toContain('three'); 505 | })); 506 | 507 | // Test the left select after all options are moved 508 | it('should move all elements when "move all" button is clicked', inject(function () { 509 | scope.moveOnSelect = false; 510 | scope.$apply(); 511 | // move all 512 | element.find('.box1 .moveall').click(); 513 | expect(scope.model.length).toBe(6); 514 | })); 515 | 516 | // Test the right select after options are selected and removed 517 | it('should remove elements when "remove" button is clicked', inject(function () { 518 | scope.moveOnSelect = false; 519 | // select 'one','two','three' 520 | scope.model = ['one','two','three']; 521 | scope.$apply(); 522 | expect(scope.model.length).toBe(3); 523 | // move two 524 | element.find('.box2 select option').slice(0, 2).prop('selected', true).change(); 525 | element.find('.box2 .remove').click(); 526 | expect(scope.model.length).toBe(1); 527 | })); 528 | 529 | // Test the right select after all options are removed 530 | it('should remove all elements when "remove all" button is clicked', inject(function () { 531 | scope.moveOnSelect = false; 532 | // select 'one','two','three' 533 | scope.model = ['one','two','three']; 534 | scope.$apply(); 535 | expect(scope.model.length).toBe(3); 536 | // move all 537 | element.find('.box2 .removeall').click(); 538 | expect(scope.model.length).toBe(0); 539 | })); 540 | }); 541 | 542 | describe('preserve', function() { 543 | var element; 544 | beforeEach(function() { 545 | element = compileDirective('preserve'); 546 | }); 547 | 548 | // Test both selects after options are selected, then moved or removed 549 | it('should not preserve any selection', inject(function () { 550 | // select 'four' and move it 551 | element.find('.box1 select option').slice(0, 1).prop('selected', true).change(); 552 | element.find('.box1 .move').click(); 553 | // no option should be selected after the move 554 | expect(element.find('select option:selected').length).toBe(0); 555 | })); 556 | 557 | // Test both selects after options are selected, then moved or removed 558 | it('should preserve moved selections only', inject(function () { 559 | scope.preserveSelection = 'moved'; 560 | scope.$apply(); 561 | // select 'four' among the unselected list 562 | element.find('.box1 select option').slice(0, 1).prop('selected', true).change(); 563 | // select 'one' among the selected list 564 | element.find('.box2 select option').slice(0, 1).prop('selected', true).change(); 565 | // move 'four' to the selected list 566 | element.find('.box1 .move').click(); 567 | // only 'four' should be selected 568 | expect(element.find('select option:selected').length).toBe(1); 569 | expect(element.find('select option:selected').text()).toBe('four'); 570 | })); 571 | 572 | // Test both selects after options are selected, then moved or removed 573 | it('should preserve all selections', inject(function () { 574 | scope.preserveSelection = 'all'; 575 | scope.$apply(); 576 | // select 'four' among the unselected list 577 | element.find('.box1 select option').slice(0, 1).prop('selected', true).change(); 578 | // select 'one' among the selected list 579 | element.find('.box2 select option').slice(0, 1).prop('selected', true).change(); 580 | // move 'four' to the selected list 581 | element.find('.box1 .move').click(); 582 | // both 'one' and 'four' should be selected 583 | expect(element.find('select option:selected').length).toBe(2); 584 | expect(Array.prototype.map.call(element.find('select option:selected'), getText)).toContain('one'); 585 | expect(Array.prototype.map.call(element.find('select option:selected'), getText)).toContain('four'); 586 | })); 587 | }); 588 | 589 | describe('postfix and name', function() { 590 | var element; 591 | beforeEach(function() { 592 | element = compileDirective('lists'); 593 | }); 594 | 595 | var testNamesIds = function(postfix) { 596 | var thePostfix = postfix || '_helper'; 597 | expect(element.find('.box1 select').attr('name')).toBe('mySelect' + thePostfix + '1'); 598 | expect(element.find('.box2 select').attr('name')).toBe('mySelect' + thePostfix + '2'); 599 | }; 600 | 601 | it('should generate the correct ids', function() { 602 | expect(element.find('.box1 > label').attr('for')).toBe('bootstrap-duallistbox-nonselected-list_mySelect'); 603 | expect(element.find('.box1 select').attr('id')).toBe('bootstrap-duallistbox-nonselected-list_mySelect'); 604 | expect(element.find('.box2 > label').attr('for')).toBe('bootstrap-duallistbox-selected-list_mySelect'); 605 | expect(element.find('.box2 select').attr('id')).toBe('bootstrap-duallistbox-selected-list_mySelect'); 606 | }); 607 | 608 | // Test the default names and ids 609 | it('should generate the default names and ids', inject(function () { 610 | testNamesIds(scope.postfix); 611 | })); 612 | 613 | // Test the default names and ids 614 | it('should generate custom names and ids', inject(function () { 615 | scope.postfix = '_my-postfix_'; 616 | scope.$apply(); 617 | testNamesIds(scope.postfix); 618 | })); 619 | }); 620 | 621 | }); 622 | --------------------------------------------------------------------------------