├── .yo-rc.json ├── ng-package.json ├── public_api.ts ├── src ├── tsconfig.es5.json ├── models │ └── index.ts ├── package.json ├── index.ts ├── dual-list-box.component.css ├── array.pipes.ts ├── dual-list-box.component.html ├── tests │ ├── array.pipes.spec.ts │ └── dual-list-box.component.spec.ts └── dual-list-box.component.ts ├── .npmignore ├── tsconfig.json ├── base.spec.ts ├── .travis.yml ├── .gitignore ├── karma.conf.js ├── package.json ├── tslint.json ├── README.MD ├── tools └── gulp │ └── inline-resources.js └── gulpfile.js /.yo-rc.json: -------------------------------------------------------------------------------- 1 | { 2 | "generator-angular2-library": { 3 | "promptValues": { 4 | "gitRepositoryUrl": "https://github.com/matsura/ng2-dual-list-box" 5 | } 6 | } 7 | } -------------------------------------------------------------------------------- /ng-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/ng-packagr/ng-package.schema.json", 3 | "lib": { 4 | "entryFile": "public_api.ts" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /public_api.ts: -------------------------------------------------------------------------------- 1 | export * from './src/index'; 2 | export * from './src/models'; 3 | export * from './src/dual-list-box.component'; 4 | export * from './src/array.pipes'; 5 | -------------------------------------------------------------------------------- /src/tsconfig.es5.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "../out-tsc/app", 4 | "baseUrl": "./", 5 | "module": "es2015", 6 | "types": [] 7 | }, 8 | "files": [ 9 | "test.ts", 10 | "**/*.spec.ts" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /src/models/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Helper interface for listbox items 3 | */ 4 | export interface IListBoxItem { 5 | value: string; 6 | text: string; 7 | } 8 | /** 9 | * Helper interface to emit event when 10 | * items are moved between boxes 11 | */ 12 | export interface IItemsMovedEvent { 13 | available: Array<{}>; 14 | selected: Array<{}>; 15 | movedItems: Array<{}>; 16 | from: 'selected' | 'available'; 17 | to: 'selected' | 'available'; 18 | } 19 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Node 2 | node_modules/* 3 | npm-debug.log 4 | docs/* 5 | # DO NOT IGNORE TYPESCRIPT FILES FOR NPM 6 | # TypeScript 7 | # *.js 8 | # *.map 9 | # *.d.ts 10 | 11 | # JetBrains 12 | .idea 13 | .project 14 | .settings 15 | .idea/* 16 | *.iml 17 | 18 | # VS Code 19 | .vscode/* 20 | 21 | # Windows 22 | Thumbs.db 23 | Desktop.ini 24 | 25 | # Mac 26 | .DS_Store 27 | **/.DS_Store 28 | 29 | # Ngc generated files 30 | **/*.ngfactory.ts 31 | 32 | # Library files 33 | src/* 34 | build/* 35 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "outDir": "./dist/out-tsc", 5 | "sourceMap": true, 6 | "declaration": false, 7 | "moduleResolution": "node", 8 | "emitDecoratorMetadata": true, 9 | "experimentalDecorators": true, 10 | "target": "es5", 11 | "typeRoots": [ 12 | "node_modules/@types" 13 | ], 14 | "lib": [ 15 | "es2016", 16 | "dom" 17 | ] 18 | }, 19 | "include": [ 20 | "src/index.ts" 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /base.spec.ts: -------------------------------------------------------------------------------- 1 | import 'core-js'; 2 | import 'zone.js/dist/zone'; 3 | import 'zone.js/dist/long-stack-trace-zone'; 4 | import 'zone.js/dist/proxy'; 5 | import 'zone.js/dist/sync-test'; 6 | import 'zone.js/dist/jasmine-patch'; 7 | import 'zone.js/dist/async-test'; 8 | import 'zone.js/dist/fake-async-test'; 9 | 10 | import { TestBed } from '@angular/core/testing'; 11 | import { BrowserDynamicTestingModule, platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing'; 12 | 13 | TestBed.initTestEnvironment(BrowserDynamicTestingModule, platformBrowserDynamicTesting()); -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | #.travis.yml 2 | 3 | language: node_js 4 | node_js: 5 | - "6.9" 6 | 7 | branches: 8 | only: 9 | - master 10 | 11 | before_script: 12 | - npm install -g surge 13 | - npm install -g gulp 14 | - npm install -g tsc 15 | 16 | script: 17 | - npm test 18 | - npm run build 19 | - npm run docs:build 20 | 21 | after_success: 'npm run coveralls' 22 | 23 | notifications: 24 | email: 25 | on_failure: change 26 | on_success: change 27 | 28 | deploy: 29 | provider: surge 30 | project: ./docs/ 31 | domain: http://ng2-duallistbox-docs.surge.sh/ 32 | skip_cleanup: true -------------------------------------------------------------------------------- /src/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ng2-dual-list-box", 3 | "version": "1.2.1", 4 | "repository": { 5 | "type": "git", 6 | "url": "https://github.com/matsura/ng2-dual-list-box" 7 | }, 8 | "author": { 9 | "name": "Eldar Granulo", 10 | "email": "eldar32@gmail.com" 11 | }, 12 | "keywords": [ 13 | "angular" 14 | ], 15 | "license": "MIT", 16 | "bugs": { 17 | "url": "https://github.com/matsura/ng2-dual-list-box/issues" 18 | }, 19 | "module": "ng2-dual-list-box.js", 20 | "typings": "ng2-dual-list-box.d.ts", 21 | "peerDependencies": { 22 | "@angular/core": "^4.0.0", 23 | "rxjs": "^5.1.0", 24 | "zone.js": "^0.8.4" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Node 2 | node_modules/* 3 | npm-debug.log 4 | 5 | # TypeScript 6 | src/*.js 7 | src/*.map 8 | src/*.d.ts 9 | src/tests/*.js 10 | src/tests/*.js.map 11 | src/tests/*.d.ts 12 | src/**/*.js 13 | src/**/*.js.map 14 | 15 | # JetBrains 16 | .idea 17 | .project 18 | .settings 19 | .idea/* 20 | *.iml 21 | 22 | # VS Code 23 | .vscode/* 24 | 25 | # Windows 26 | Thumbs.db 27 | Desktop.ini 28 | 29 | # Mac 30 | .DS_Store 31 | **/.DS_Store 32 | 33 | # Ngc generated files 34 | **/*.ngfactory.ts 35 | 36 | # Build files 37 | dist/* 38 | 39 | # Coverage 40 | coverage/* 41 | 42 | # Docs 43 | docs/* 44 | 45 | # Root base for tests 46 | base.spec.js 47 | base.spec.js.map 48 | base.spec.d.ts 49 | 50 | # Coveralls 51 | .coveralls.yml 52 | 53 | # Ng pkg 54 | .ng_pkg_build/* 55 | build/* -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { NgModule, ModuleWithProviders } from '@angular/core'; 2 | import { CommonModule } from '@angular/common'; 3 | import { ReactiveFormsModule } from '@angular/forms'; 4 | 5 | import { DualListBoxComponent } from './dual-list-box.component'; 6 | import { ArraySortPipe, ArrayFilterPipe } from './array.pipes'; 7 | 8 | @NgModule({ 9 | imports: [ 10 | CommonModule, 11 | ReactiveFormsModule 12 | ], 13 | declarations: [ 14 | ArraySortPipe, 15 | ArrayFilterPipe, 16 | DualListBoxComponent 17 | ], 18 | exports: [ 19 | ArraySortPipe, 20 | ArrayFilterPipe, 21 | DualListBoxComponent 22 | ] 23 | }) 24 | export class DualListBoxModule { 25 | static forRoot(): ModuleWithProviders { 26 | return { 27 | ngModule: DualListBoxModule 28 | }; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/dual-list-box.component.css: -------------------------------------------------------------------------------- 1 | .list-box { 2 | min-height: 200px; 3 | width: 100%; 4 | } 5 | .top100 { 6 | margin-top: 100px; 7 | } 8 | .top80 { 9 | margin-top: 80px; 10 | } 11 | .bottom10 { 12 | margin-bottom: 10px; 13 | } 14 | .vertical-spacing-5 { 15 | margin-top: 5px; 16 | margin-bottom: 5px; 17 | } 18 | .center-block { 19 | min-height: 50px; 20 | } 21 | /* Small Devices, Tablets */ 22 | @media only screen and (max-width : 768px) { 23 | .sm-spacing { 24 | margin-top: 10px; 25 | margin-bottom: 10px; 26 | } 27 | } 28 | /* Tablets in portrait */ 29 | @media only screen and (min-width : 768px) and (max-width : 992px) { 30 | .sm-spacing { 31 | margin-top: 10px; 32 | margin-bottom: 10px; 33 | } 34 | } 35 | /* Extra Small Devices, Phones */ 36 | @media only screen and (max-width : 480px) { 37 | .sm-spacing { 38 | margin-top: 10px; 39 | margin-bottom: 10px; 40 | } 41 | } -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | const ENV = process.env.npm_lifecycle_event; 2 | const WATCH = ENV === 'test:watch'; 3 | 4 | module.exports = function (config) { 5 | const CONFIGURATION = { 6 | frameworks: ['jasmine', 'karma-typescript'], 7 | files: [{ 8 | pattern: 'base.spec.ts' 9 | }, 10 | { 11 | pattern: 'src/**/*.+(ts|html)' 12 | } 13 | ], 14 | preprocessors: { 15 | '**/*.ts': ['karma-typescript'] 16 | }, 17 | coverageReporter: { 18 | dir: 'coverage', 19 | subdir: '.', 20 | type: 'lcovonly' 21 | // Would output the results into: .'/coverage/' 22 | }, 23 | karmaTypescriptConfig: { 24 | bundlerOptions: { 25 | entrypoints: /\.spec\.ts$/, 26 | transforms: [ 27 | require('karma-typescript-angular2-transform') 28 | ] 29 | }, 30 | compilerOptions: { 31 | lib: ['ES2015', 'DOM'] 32 | }, 33 | exclude: [ 34 | "demo" 35 | ] 36 | }, 37 | reporters: ['progress', 'coverage'], 38 | browsers: ['PhantomJS_custom'], 39 | colors: true, 40 | logLevel: config.LOG_INFO, 41 | singleRun: !WATCH, 42 | autoWatch: WATCH, 43 | client: { 44 | captureConsole: true 45 | }, 46 | customLaunchers: { 47 | 'PhantomJS_custom': { 48 | base: 'PhantomJS', 49 | options: { 50 | windowName: 'my-window', 51 | settings: { 52 | webSecurityEnabled: false 53 | } 54 | }, 55 | flags: ['--load-images=false'], 56 | debug: true 57 | } 58 | }, 59 | phantomjsLauncher: { 60 | // have phantomjs exit if a ResourceError is encountered (useful if karma exits without killing phantom) 61 | exitOnResourceError: false 62 | } 63 | }; 64 | 65 | config.set(CONFIGURATION); 66 | }; -------------------------------------------------------------------------------- /src/array.pipes.ts: -------------------------------------------------------------------------------- 1 | import { Pipe, PipeTransform } from '@angular/core'; 2 | const orderby = require('lodash.orderby'); 3 | 4 | /** 5 | * Utility class to not hardcode sort directions 6 | */ 7 | export class SortOptions { 8 | /** 9 | * Static property to defined ASC and DESC values 10 | * to avoid hardcoding and repeating 11 | * replaces string enums 12 | */ 13 | static direction: { 14 | ASC: string, 15 | DESC: string 16 | } = { 17 | ASC: 'ASC', 18 | DESC: 'DESC' 19 | }; 20 | } 21 | 22 | /** 23 | * Pipe used to sort arrays by using lodash 24 | * Takes array and array of 2 strings(parameters), key and direction 25 | * direction must be either ASC or DESC 26 | */ 27 | @Pipe({ 28 | name: 'arraySort' 29 | }) 30 | export class ArraySortPipe implements PipeTransform { 31 | 32 | transform(array: Array<{}>, args: string[]): Array | Array<{}> { 33 | 34 | array = array || []; 35 | 36 | if (typeof args === 'undefined' || args.length !== 2) { 37 | return array; 38 | } 39 | 40 | const [key, direction] = args; 41 | 42 | if (direction !== SortOptions.direction.ASC && direction !== SortOptions.direction.DESC) { 43 | return array; 44 | } 45 | 46 | // if there is no key we assume item is of string type 47 | return orderby(array, (item: {} | string) => item.hasOwnProperty(key) ? item[key] : item, direction.toLowerCase()); 48 | } 49 | } 50 | 51 | /** 52 | * Pipe used to filter array, takes input array and 53 | * array of 2 arguments, key of object and search term 54 | * if key does not exist, pipe assumes the item is string 55 | */ 56 | @Pipe({ 57 | name: 'arrayFilter' 58 | }) 59 | export class ArrayFilterPipe implements PipeTransform { 60 | 61 | transform(array: Array<{}>, args: string[]): Array | Array<{}> { 62 | 63 | array = array || []; 64 | 65 | if (typeof args === 'undefined' || args.length !== 2 ) { 66 | return array; 67 | } 68 | 69 | const [key, searchTerm] = args; 70 | 71 | if (searchTerm.trim() === '') { 72 | return array; 73 | } 74 | 75 | return array.filter((item: {}) => item[key].toString().toLowerCase().search(searchTerm.toLowerCase().trim()) >= 0); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ng2-dual-list-box", 3 | "version": "1.2.1", 4 | "scripts": { 5 | "build": "npm run packagr", 6 | "build:watch": "gulp", 7 | "docs": "npm run docs:build", 8 | "docs:build": "compodoc -p tsconfig.json -n ng2-dual-list-box -d docs --hideGenerator", 9 | "docs:serve": "npm run docs:build -- -s", 10 | "docs:watch": "npm run docs:build -- -s -w", 11 | "lint": "tslint --type-check --project tsconfig.json src/**/*.ts", 12 | "test": "tsc && karma start", 13 | "coveralls": "cat ./coverage/lcov.info | node node_modules/.bin/coveralls", 14 | "packagr": "ng-packagr -p package.json" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "https://github.com/matsura/ng2-dual-list-box" 19 | }, 20 | "author": { 21 | "name": "Eldar Granulo", 22 | "email": "eldar32@gmail.com" 23 | }, 24 | "keywords": [ 25 | "angular" 26 | ], 27 | "license": "MIT", 28 | "bugs": { 29 | "url": "https://github.com/matsura/ng2-dual-list-box/issues" 30 | }, 31 | "devDependencies": { 32 | "@angular/common": "^5.2.7", 33 | "@angular/compiler": "^5.2.7", 34 | "@angular/compiler-cli": "^5.2.7", 35 | "@angular/core": "^5.2.7", 36 | "@angular/forms": "^5.2.7", 37 | "@angular/platform-browser": "^5.2.7", 38 | "@angular/platform-browser-dynamic": "^5.2.7", 39 | "@compodoc/compodoc": "^1.0.0-beta.7", 40 | "@types/jasmine": "2.5.38", 41 | "@types/lodash": "^4.14.64", 42 | "@types/node": "~6.0.60", 43 | "codelyzer": "~2.0.0", 44 | "core-js": "^2.4.1", 45 | "coveralls": "^2.13.1", 46 | "del": "^2.2.2", 47 | "gulp": "^3.9.1", 48 | "gulp-rename": "^1.2.2", 49 | "gulp-rollup": "^2.11.0", 50 | "jasmine-core": "~2.5.2", 51 | "jasmine-spec-reporter": "~3.2.0", 52 | "karma": "~1.4.1", 53 | "karma-chrome-launcher": "~2.0.0", 54 | "karma-cli": "~1.0.1", 55 | "karma-coverage-istanbul-reporter": "^0.2.0", 56 | "karma-intl-shim": "^1.0.3", 57 | "karma-jasmine": "~1.1.0", 58 | "karma-jasmine-html-reporter": "^0.2.2", 59 | "karma-phantomjs-launcher": "^1.0.2", 60 | "karma-typescript": "^3.0.0", 61 | "karma-typescript-angular2-transform": "^1.0.0", 62 | "ng-packagr": "^2.1.0", 63 | "node-sass": "^4.5.2", 64 | "node-watch": "^0.5.2", 65 | "phantomjs-prebuilt": "^2.1.7", 66 | "protractor": "~5.1.0", 67 | "rollup": "^0.41.6", 68 | "run-sequence": "^1.2.2", 69 | "rxjs": "^5.1.0", 70 | "ts-node": "~2.0.0", 71 | "tslint": "~4.5.0", 72 | "typescript": "2.6.2", 73 | "zone.js": "^0.8.4" 74 | }, 75 | "engines": { 76 | "node": ">=6.0.0" 77 | }, 78 | "dependencies": { 79 | "lodash.differencewith": "^4.5.0", 80 | "lodash.intersectionwith": "^4.4.0", 81 | "lodash.isequal": "^4.5.0", 82 | "lodash.orderby": "^4.6.0", 83 | "tsickle": "^0.27.2" 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rulesDirectory": [ 3 | "node_modules/codelyzer" 4 | ], 5 | "rules": { 6 | "class-name": true, 7 | "comment-format": [ 8 | true, 9 | "check-space" 10 | ], 11 | "curly": true, 12 | "eofline": true, 13 | "forin": true, 14 | "indent": [ 15 | true, 16 | "spaces" 17 | ], 18 | "label-position": true, 19 | "max-line-length": [ 20 | true, 21 | 140 22 | ], 23 | "member-access": false, 24 | "member-ordering": [ 25 | true, 26 | "static-before-instance", 27 | "variables-before-functions" 28 | ], 29 | "no-arg": true, 30 | "no-bitwise": true, 31 | "no-console": [ 32 | true, 33 | "debug", 34 | "info", 35 | "time", 36 | "timeEnd", 37 | "trace" 38 | ], 39 | "no-construct": true, 40 | "no-debugger": true, 41 | "no-duplicate-variable": true, 42 | "no-empty": false, 43 | "no-eval": true, 44 | "no-inferrable-types": true, 45 | "no-shadowed-variable": true, 46 | "no-string-literal": false, 47 | "no-switch-case-fall-through": true, 48 | "no-trailing-whitespace": true, 49 | "no-unused-expression": true, 50 | "no-unused-variable": true, 51 | "no-use-before-declare": true, 52 | "no-var-keyword": true, 53 | "object-literal-sort-keys": false, 54 | "one-line": [ 55 | true, 56 | "check-open-brace", 57 | "check-catch", 58 | "check-else", 59 | "check-whitespace" 60 | ], 61 | "quotemark": [ 62 | true, 63 | "single" 64 | ], 65 | "radix": true, 66 | "semicolon": [ 67 | "always" 68 | ], 69 | "triple-equals": [ 70 | true, 71 | "allow-null-check" 72 | ], 73 | "typedef-whitespace": [ 74 | true, 75 | { 76 | "call-signature": "nospace", 77 | "index-signature": "nospace", 78 | "parameter": "nospace", 79 | "property-declaration": "nospace", 80 | "variable-declaration": "nospace" 81 | } 82 | ], 83 | "variable-name": false, 84 | "whitespace": [ 85 | true, 86 | "check-branch", 87 | "check-decl", 88 | "check-operator", 89 | "check-separator", 90 | "check-type" 91 | ], 92 | "directive-selector": [true, "attribute", "", "camelCase"], 93 | "component-selector": [true, "element", "", "kebab-case"], 94 | "use-input-property-decorator": true, 95 | "use-output-property-decorator": true, 96 | "use-host-property-decorator": true, 97 | "no-input-rename": true, 98 | "no-output-rename": true, 99 | "use-life-cycle-interface": true, 100 | "use-pipe-transform-interface": true, 101 | "component-class-suffix": true, 102 | "directive-class-suffix": true 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/dual-list-box.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |

{{availableText}}

4 | 6 | 11 |
12 |
13 | 19 | 25 | 31 | 37 |
38 |
39 |

{{selectedText}}

40 | 42 | 47 |
48 |
-------------------------------------------------------------------------------- /README.MD: -------------------------------------------------------------------------------- 1 | # ng2-dual-list-box 2 | 3 | An Angular 4 component inspired by the following jQuery library: https://github.com/Geodan/DualListBox 4 | 5 | 6 | Uses Bootstrap 3 classes for styling and responsiveness 7 | 8 | [![NPM](https://nodei.co/npm/ng2-dual-list-box.png?downloads=true&downloadRank=true)](https://npmjs.org/package/ng2-dual-list-box) 9 | 10 | [![Build Status](https://travis-ci.org/matsura/ng2-dual-list-box.svg?branch=master)](https://travis-ci.org/matsura/ng2-dual-list-box) [![Coverage Status](https://coveralls.io/repos/github/matsura/ng2-dual-list-box/badge.svg?branch=master)](https://coveralls.io/github/matsura/ng2-dual-list-box?branch=master) 11 | 12 | ## Documentation 13 | 14 | Full documentation available at http://ng2-duallistbox-docs.surge.sh/ 15 | 16 | ## Demo 17 | 18 | http://ng2-duallistbox-demo.surge.sh/ 19 | 20 | ## Installation 21 | 22 | To install this library, run: 23 | 24 | ```bash 25 | $ npm install ng-dual-list-box --save 26 | ``` 27 | 28 | and then from your Angular `AppModule`: 29 | 30 | ```typescript 31 | import { BrowserModule } from '@angular/platform-browser'; 32 | import { NgModule } from '@angular/core'; 33 | 34 | import { AppComponent } from './app.component'; 35 | 36 | // Import your library 37 | import { DualListBoxModule } from 'ng-dual-list-box'; 38 | 39 | @NgModule({ 40 | declarations: [ 41 | AppComponent 42 | ], 43 | imports: [ 44 | BrowserModule, 45 | DualListBoxModule.forRoot() 46 | ], 47 | providers: [], 48 | bootstrap: [AppComponent] 49 | }) 50 | export class AppModule { } 51 | ``` 52 | 53 | Use it in your template like this. Check the documentation for other available fields, but these are mandatory 54 | 55 | ```html 56 |
57 |
58 | 62 |
63 |
64 | ``` 65 | 66 | You can also use ngModel and formControlName. When this is used the variable or form control used will have the value of the selected list box. 67 | 68 | ```html 69 |
70 |
71 | 76 |
77 |
78 | ``` 79 | 80 | ```html 81 |
82 |
83 | 88 |
89 |
90 | ``` 91 | 92 | 93 | 94 | 95 | ## Development 96 | 97 | To generate all `*.js`, `*.d.ts` and `*.metadata.json` files: 98 | 99 | ```bash 100 | $ npm run build 101 | ``` 102 | 103 | To lint all `*.ts` files: 104 | 105 | ```bash 106 | $ npm run lint 107 | ``` 108 | 109 | ## License 110 | 111 | MIT © [Eldar Granulo](mailto:eldar32@gmail.com) 112 | -------------------------------------------------------------------------------- /src/tests/array.pipes.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed, inject } from '@angular/core/testing'; 2 | import { CommonModule } from '@angular/common'; 3 | import * as _ from 'lodash'; 4 | 5 | import { ArrayFilterPipe, ArraySortPipe, SortOptions } from '../array.pipes'; 6 | 7 | describe('Array filter pipe ', (): void => { 8 | 9 | let testArray: Array<{ key: string, value: number }> = [{ key: 'Eldar', value: 10 }, { key: 'Sarajevo', value: 200 }]; 10 | 11 | beforeEach((done: Function): void => { 12 | TestBed.configureTestingModule({ 13 | declarations: [ 14 | ArrayFilterPipe 15 | ], 16 | imports: [ 17 | CommonModule 18 | ], 19 | providers: [ 20 | ArrayFilterPipe 21 | ] 22 | }); 23 | 24 | done(); 25 | }); 26 | 27 | it('should return empty array if undefined is passed', 28 | inject([ArrayFilterPipe], (pipe: ArrayFilterPipe): void => { 29 | 30 | expect(pipe.transform(undefined, ['key', 'Eldar'])).toEqual([]); 31 | })); 32 | 33 | it('should return empty array if array of length 0 is passed', 34 | inject([ArrayFilterPipe], (pipe: ArrayFilterPipe): void => { 35 | 36 | expect(pipe.transform([], ['key', 'value'])).toEqual([]); 37 | })); 38 | 39 | it('should return original array if less or more than 2 arguments are passed', 40 | inject([ArrayFilterPipe], (pipe: ArrayFilterPipe): void => { 41 | 42 | expect(_.isEqual(testArray, pipe.transform(testArray, []))).toBe(true); 43 | expect(_.isEqual(testArray, pipe.transform(testArray, ['key']))).toBe(true); 44 | expect(_.isEqual(testArray, pipe.transform(testArray, ['key', 'value', 'value'] ))).toBe(true); 45 | })); 46 | 47 | it('should return original array if search term consisting of only spaces is passed', 48 | inject([ArrayFilterPipe], (pipe: ArrayFilterPipe): void => { 49 | 50 | expect(_.isEqual(testArray, pipe.transform(testArray, ['key', ' ']))).toBe(true); 51 | })); 52 | 53 | it('should filter array based on passed key and search term and return array containing filtered values', 54 | inject([ArrayFilterPipe], (pipe: ArrayFilterPipe): void => { 55 | 56 | const filteredArray: Array<{}> = pipe.transform(testArray, ['key', 'Eldar']); 57 | expect(filteredArray.length).toEqual(1); 58 | expect((filteredArray[0] as { key: string, value: number }).key).toEqual('Eldar'); 59 | })); 60 | 61 | }); 62 | 63 | describe('Array sort pipe ', (): void => { 64 | 65 | let testArray: Array<{ key: string, value: number }> = [{ key: 'Sarajevo', value: 10 }, 66 | { key: 'Eldar', value: 200 }]; 67 | 68 | beforeEach((done: Function): void => { 69 | TestBed.configureTestingModule({ 70 | declarations: [ 71 | ArraySortPipe 72 | ], 73 | imports: [ 74 | CommonModule 75 | ], 76 | providers: [ 77 | ArraySortPipe 78 | ] 79 | }); 80 | 81 | done(); 82 | }); 83 | 84 | it('should return empty array if undefined is passed', 85 | inject([ArraySortPipe], (pipe: ArraySortPipe): void => { 86 | 87 | expect(pipe.transform(undefined, ['key', 'Eldar'])).toEqual([]); 88 | })); 89 | 90 | it('should return empty array if array of length 0 is passed', 91 | inject([ArraySortPipe], (pipe: ArraySortPipe): void => { 92 | 93 | expect(pipe.transform([], ['key', 'value'])).toEqual([]); 94 | })); 95 | 96 | it('should return original array if undefined is passed as arguments', 97 | inject([ArraySortPipe], (pipe: ArraySortPipe): void => { 98 | 99 | expect(_.isEqual(testArray, pipe.transform(testArray, undefined))).toBe(true); 100 | })); 101 | 102 | it('should return original array if less or more than 2 arguments are passed', inject([ArraySortPipe], (pipe: ArraySortPipe): void => { 103 | 104 | expect(_.isEqual(testArray, pipe.transform(testArray, []))).toBe(true); 105 | expect(_.isEqual(testArray, pipe.transform(testArray, ['key']))).toBe(true); 106 | expect(_.isEqual(testArray, pipe.transform(testArray, ['key', 'value', 'value']))).toBe(true); 107 | })); 108 | 109 | it('should sort an array in ascending order by key and ASC direction passed', inject([ArraySortPipe], (pipe: ArraySortPipe): void => { 110 | 111 | const sortedArray: Array<{}> = pipe.transform(testArray, ['key', SortOptions.direction.ASC]); 112 | expect(sortedArray.length).toEqual(2); 113 | expect((sortedArray[0] as { key: string, value: number }).key).toEqual('Eldar'); 114 | expect((sortedArray[1] as { key: string, value: number }).key).toEqual('Sarajevo'); 115 | })); 116 | 117 | it('should sort an array in descending order by key and DESC direction passed', inject([ArraySortPipe], (pipe: ArraySortPipe): void => { 118 | 119 | const sortedArray: Array<{}> = pipe.transform(testArray, ['key', SortOptions.direction.DESC]); 120 | expect(sortedArray.length).toEqual(2); 121 | expect((sortedArray[0] as { key: string, value: number }).key).toEqual('Sarajevo'); 122 | expect((sortedArray[1] as { key: string, value: number }).key).toEqual('Eldar'); 123 | })); 124 | 125 | it('should return original array if invalid direction is passed', inject([ArraySortPipe], (pipe: ArraySortPipe): void => { 126 | 127 | expect(_.isEqual(testArray, pipe.transform(testArray, ['key', 'value'] ))).toBe(true); 128 | })); 129 | 130 | }); 131 | -------------------------------------------------------------------------------- /tools/gulp/inline-resources.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | // https://github.com/filipesilva/angular-quickstart-lib/blob/master/inline-resources.js 3 | 'use strict'; 4 | 5 | const fs = require('fs'); 6 | const path = require('path'); 7 | const glob = require('glob'); 8 | const sass = require('node-sass'); 9 | 10 | /** 11 | * Simple Promiseify function that takes a Node API and return a version that supports promises. 12 | * We use promises instead of synchronized functions to make the process less I/O bound and 13 | * faster. It also simplifies the code. 14 | */ 15 | function promiseify(fn) { 16 | return function () { 17 | const args = [].slice.call(arguments, 0); 18 | return new Promise((resolve, reject) => { 19 | fn.apply(this, args.concat([function (err, value) { 20 | if (err) { 21 | reject(err); 22 | } else { 23 | resolve(value); 24 | } 25 | }])); 26 | }); 27 | }; 28 | } 29 | 30 | const readFile = promiseify(fs.readFile); 31 | const writeFile = promiseify(fs.writeFile); 32 | 33 | /** 34 | * Inline resources in a tsc/ngc compilation. 35 | * @param projectPath {string} Path to the project. 36 | */ 37 | function inlineResources(projectPath) { 38 | 39 | // Match only TypeScript files in projectPath. 40 | const files = glob.sync('**/*.ts', {cwd: projectPath}); 41 | 42 | // For each file, inline the templates and styles under it and write the new file. 43 | return Promise.all(files.map(filePath => { 44 | const fullFilePath = path.join(projectPath, filePath); 45 | return readFile(fullFilePath, 'utf-8') 46 | .then(content => inlineResourcesFromString(content, url => { 47 | // Resolve the template url. 48 | return path.join(path.dirname(fullFilePath), url); 49 | })) 50 | .then(content => writeFile(fullFilePath, content)) 51 | .catch(err => { 52 | console.error('An error occured: ', err); 53 | }); 54 | })); 55 | } 56 | 57 | /** 58 | * Inline resources from a string content. 59 | * @param content {string} The source file's content. 60 | * @param urlResolver {Function} A resolver that takes a URL and return a path. 61 | * @returns {string} The content with resources inlined. 62 | */ 63 | function inlineResourcesFromString(content, urlResolver) { 64 | // Curry through the inlining functions. 65 | return [ 66 | inlineTemplate, 67 | inlineStyle, 68 | removeModuleId 69 | ].reduce((content, fn) => fn(content, urlResolver), content); 70 | } 71 | 72 | /** 73 | * Inline the templates for a source file. Simply search for instances of `templateUrl: ...` and 74 | * replace with `template: ...` (with the content of the file included). 75 | * @param content {string} The source file's content. 76 | * @param urlResolver {Function} A resolver that takes a URL and return a path. 77 | * @return {string} The content with all templates inlined. 78 | */ 79 | function inlineTemplate(content, urlResolver) { 80 | return content.replace(/templateUrl:\s*'([^']+?\.html)'/g, function (m, templateUrl) { 81 | const templateFile = urlResolver(templateUrl); 82 | const templateContent = fs.readFileSync(templateFile, 'utf-8'); 83 | const shortenedTemplate = templateContent 84 | .replace(/([\n\r]\s*)+/gm, ' ') 85 | .replace(/"/g, '\\"'); 86 | return `template: "${shortenedTemplate}"`; 87 | }); 88 | } 89 | 90 | 91 | /** 92 | * Inline the styles for a source file. Simply search for instances of `styleUrls: [...]` and 93 | * replace with `styles: [...]` (with the content of the file included). 94 | * @param urlResolver {Function} A resolver that takes a URL and return a path. 95 | * @param content {string} The source file's content. 96 | * @return {string} The content with all styles inlined. 97 | */ 98 | function inlineStyle(content, urlResolver) { 99 | return content.replace(/styleUrls:\s*(\[[\s\S]*?\])/gm, function (m, styleUrls) { 100 | const urls = eval(styleUrls); 101 | return 'styles: [' 102 | + urls.map(styleUrl => { 103 | const styleFile = urlResolver(styleUrl); 104 | const originContent = fs.readFileSync(styleFile, 'utf-8'); 105 | const styleContent = styleFile.endsWith('.scss') ? buildSass(originContent, styleFile) : originContent; 106 | const shortenedStyle = styleContent 107 | .replace(/([\n\r]\s*)+/gm, ' ') 108 | .replace(/"/g, '\\"'); 109 | return `"${shortenedStyle}"`; 110 | }) 111 | .join(',\n') 112 | + ']'; 113 | }); 114 | } 115 | 116 | /** 117 | * build sass content to css 118 | * @param content {string} the css content 119 | * @param sourceFile {string} the scss file sourceFile 120 | * @return {string} the generated css, empty string if error occured 121 | */ 122 | function buildSass(content, sourceFile) { 123 | try { 124 | const result = sass.renderSync({data: content}); 125 | return result.css.toString() 126 | } catch (e) { 127 | console.error('\x1b[41m'); 128 | console.error('at ' + sourceFile + ':' + e.line + ":" + e.column); 129 | console.error(e.formatted); 130 | console.error('\x1b[0m'); 131 | return ""; 132 | } 133 | } 134 | 135 | /** 136 | * Remove every mention of `moduleId: module.id`. 137 | * @param content {string} The source file's content. 138 | * @returns {string} The content with all moduleId: mentions removed. 139 | */ 140 | function removeModuleId(content) { 141 | return content.replace(/\s*moduleId:\s*module\.id\s*,?\s*/gm, ''); 142 | } 143 | 144 | module.exports = inlineResources; 145 | module.exports.inlineResourcesFromString = inlineResourcesFromString; 146 | 147 | // Run inlineResources if module is being called directly from the CLI with arguments. 148 | if (require.main === module && process.argv.length > 2) { 149 | console.log('Inlining resources from project:', process.argv[2]); 150 | return inlineResources(process.argv[2]); 151 | } 152 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | var gulp = require('gulp'), 3 | path = require('path'), 4 | ngc = require('@angular/compiler-cli/src/main').main, 5 | rollup = require('gulp-rollup'), 6 | rename = require('gulp-rename'), 7 | del = require('del'), 8 | runSequence = require('run-sequence'), 9 | inlineResources = require('./tools/gulp/inline-resources'); 10 | 11 | const rootFolder = path.join(__dirname); 12 | const srcFolder = path.join(rootFolder, 'src'); 13 | const tmpFolder = path.join(rootFolder, '.tmp'); 14 | const buildFolder = path.join(rootFolder, 'build'); 15 | const distFolder = path.join(rootFolder, 'dist'); 16 | 17 | /** 18 | * 1. Delete /dist folder 19 | */ 20 | gulp.task('clean:dist', function () { 21 | return deleteFolders([distFolder]); 22 | }); 23 | 24 | /** 25 | * 2. Clone the /src folder into /.tmp. If an npm link inside /src has been made, 26 | * then it's likely that a node_modules folder exists. Ignore this folder 27 | * when copying to /.tmp. 28 | */ 29 | gulp.task('copy:source', function () { 30 | return gulp.src([`${srcFolder}/**/*`, `!${srcFolder}/node_modules`]) 31 | .pipe(gulp.dest(tmpFolder)); 32 | }); 33 | 34 | /** 35 | * 3. Inline template (.html) and style (.css) files into the the component .ts files. 36 | * We do this on the /.tmp folder to avoid editing the original /src files 37 | */ 38 | gulp.task('inline-resources', function () { 39 | return Promise.resolve() 40 | .then(() => inlineResources(tmpFolder)); 41 | }); 42 | 43 | 44 | /** 45 | * 4. Run the Angular compiler, ngc, on the /.tmp folder. This will output all 46 | * compiled modules to the /build folder. 47 | */ 48 | gulp.task('ngc', function () { 49 | return ngc({ 50 | project: `${tmpFolder}/tsconfig.es5.json` 51 | }) 52 | .then((exitCode) => { 53 | if (exitCode === 1) { 54 | // This error is caught in the 'compile' task by the runSequence method callback 55 | // so that when ngc fails to compile, the whole compile process stops running 56 | throw new Error('ngc compilation failed'); 57 | } 58 | }); 59 | }); 60 | 61 | /** 62 | * 5. Run rollup inside the /build folder to generate our Flat ES module and place the 63 | * generated file into the /dist folder 64 | */ 65 | gulp.task('rollup:fesm', function () { 66 | return gulp.src(`${buildFolder}/**/*.js`) 67 | // transform the files here. 68 | .pipe(rollup({ 69 | 70 | // Bundle's entry point 71 | // See https://github.com/rollup/rollup/wiki/JavaScript-API#entry 72 | entry: `${buildFolder}/index.js`, 73 | 74 | // A list of IDs of modules that should remain external to the bundle 75 | // See https://github.com/rollup/rollup/wiki/JavaScript-API#external 76 | external: [ 77 | '@angular/core', 78 | '@angular/common' 79 | ], 80 | 81 | // Format of generated bundle 82 | // See https://github.com/rollup/rollup/wiki/JavaScript-API#format 83 | format: 'es' 84 | })) 85 | .pipe(gulp.dest(distFolder)); 86 | }); 87 | 88 | /** 89 | * 6. Run rollup inside the /build folder to generate our UMD module and place the 90 | * generated file into the /dist folder 91 | */ 92 | gulp.task('rollup:umd', function () { 93 | return gulp.src(`${buildFolder}/**/*.js`) 94 | // transform the files here. 95 | .pipe(rollup({ 96 | 97 | // Bundle's entry point 98 | // See https://github.com/rollup/rollup/wiki/JavaScript-API#entry 99 | entry: `${buildFolder}/index.js`, 100 | 101 | // A list of IDs of modules that should remain external to the bundle 102 | // See https://github.com/rollup/rollup/wiki/JavaScript-API#external 103 | external: [ 104 | '@angular/core', 105 | '@angular/common' 106 | ], 107 | 108 | // Format of generated bundle 109 | // See https://github.com/rollup/rollup/wiki/JavaScript-API#format 110 | format: 'umd', 111 | 112 | // Export mode to use 113 | // See https://github.com/rollup/rollup/wiki/JavaScript-API#exports 114 | exports: 'named', 115 | 116 | // The name to use for the module for UMD/IIFE bundles 117 | // (required for bundles with exports) 118 | // See https://github.com/rollup/rollup/wiki/JavaScript-API#modulename 119 | moduleName: 'ng2-dual-list-box', 120 | 121 | // See https://github.com/rollup/rollup/wiki/JavaScript-API#globals 122 | globals: { 123 | typescript: 'ts' 124 | } 125 | 126 | })) 127 | .pipe(rename('ng2-dual-list-box.umd.js')) 128 | .pipe(gulp.dest(distFolder)); 129 | }); 130 | 131 | /** 132 | * 7. Copy all the files from /build to /dist, except .js files. We ignore all .js from /build 133 | * because with don't need individual modules anymore, just the Flat ES module generated 134 | * on step 5. 135 | */ 136 | gulp.task('copy:build', function () { 137 | return gulp.src([`${buildFolder}/**/*`, `!${buildFolder}/**/*.js`]) 138 | .pipe(gulp.dest(distFolder)); 139 | }); 140 | 141 | /** 142 | * 8. Copy package.json from /src to /dist 143 | */ 144 | gulp.task('copy:manifest', function () { 145 | return gulp.src([`${srcFolder}/package.json`]) 146 | .pipe(gulp.dest(distFolder)); 147 | }); 148 | 149 | /** 150 | * 9. Copy README.md from / to /dist 151 | */ 152 | gulp.task('copy:readme', function () { 153 | return gulp.src([path.join(rootFolder, 'README.MD')]) 154 | .pipe(gulp.dest(distFolder)); 155 | }); 156 | 157 | /** 158 | * 10. Delete /.tmp folder 159 | */ 160 | gulp.task('clean:tmp', function () { 161 | return deleteFolders([tmpFolder]); 162 | }); 163 | 164 | /** 165 | * 11. Delete /build folder 166 | */ 167 | gulp.task('clean:build', function () { 168 | return deleteFolders([buildFolder]); 169 | }); 170 | 171 | gulp.task('compile', function () { 172 | runSequence( 173 | 'clean:dist', 174 | 'copy:source', 175 | 'inline-resources', 176 | 'ngc', 177 | 'rollup:fesm', 178 | 'rollup:umd', 179 | 'copy:build', 180 | 'copy:manifest', 181 | 'copy:readme', 182 | 'clean:build', 183 | 'clean:tmp', 184 | function (err) { 185 | if (err) { 186 | console.log('ERROR:', err.message); 187 | deleteFolders([distFolder, tmpFolder, buildFolder]); 188 | } else { 189 | console.log('Compilation finished succesfully'); 190 | } 191 | }); 192 | }); 193 | 194 | /** 195 | * Watch for any change in the /src folder and compile files 196 | */ 197 | gulp.task('watch', function () { 198 | gulp.watch(`${srcFolder}/**/*`, ['compile']); 199 | }); 200 | 201 | gulp.task('clean', ['clean:dist', 'clean:tmp', 'clean:build']); 202 | 203 | gulp.task('build', ['clean', 'compile']); 204 | gulp.task('build:watch', ['build', 'watch']); 205 | gulp.task('default', ['build:watch']); 206 | 207 | /** 208 | * Deletes the specified folder 209 | */ 210 | function deleteFolders(folders) { 211 | return del(folders); 212 | } 213 | -------------------------------------------------------------------------------- /src/dual-list-box.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, Output, EventEmitter, OnInit, forwardRef } from '@angular/core'; 2 | import { FormGroup, FormBuilder, FormControl, NG_VALUE_ACCESSOR, ControlValueAccessor } from '@angular/forms'; 3 | import 'rxjs/add/operator/debounceTime'; 4 | import 'rxjs/add/operator/distinctUntilChanged'; 5 | import 'rxjs/add/operator/map'; 6 | const intersectionwith = require('lodash.intersectionwith'); 7 | const differenceWith = require('lodash.differencewith'); 8 | 9 | import { IItemsMovedEvent, IListBoxItem } from './models'; 10 | 11 | @Component({ 12 | selector: 'ng2-dual-list-box', 13 | templateUrl: 'dual-list-box.component.html', 14 | styleUrls: ['dual-list-box.component.css'], 15 | providers: [{ 16 | provide: NG_VALUE_ACCESSOR, 17 | useExisting: forwardRef(() => DualListBoxComponent), 18 | multi: true 19 | }] 20 | }) 21 | export class DualListBoxComponent implements OnInit, ControlValueAccessor { 22 | 23 | // array of items to display in left box 24 | @Input() set data(items: Array<{}>) { 25 | this.availableItems = [...(items || []).map((item: {}, index: number) => ({ 26 | value: item[this.valueField].toString(), 27 | text: item[this.textField] 28 | }))]; 29 | }; 30 | // input to set search term for available list box from the outside 31 | @Input() set availableSearch(searchTerm: string) { 32 | this.searchTermAvailable = searchTerm; 33 | this.availableSearchInputControl.setValue(searchTerm); 34 | }; 35 | // input to set search term for selected list box from the outside 36 | @Input() set selectedSearch(searchTerm: string) { 37 | this.searchTermSelected = searchTerm; 38 | this.selectedSearchInputControl.setValue(searchTerm); 39 | }; 40 | // field to use for value of option 41 | @Input() valueField = 'id'; 42 | // field to use for displaying option text 43 | @Input() textField = 'name'; 44 | // text to display as title above component 45 | @Input() title: string; 46 | // time to debounce search output in ms 47 | @Input() debounceTime = 500; 48 | // show/hide button to move all items between boxes 49 | @Input() moveAllButton = true; 50 | // text displayed over the available items list box 51 | @Input() availableText = 'Available items'; 52 | // text displayed over the selected items list box 53 | @Input() selectedText = 'Selected items'; 54 | // set placeholder text in available items list box 55 | @Input() availableFilterPlaceholder= 'Filter...'; 56 | // set placeholder text in selected items list box 57 | @Input() selectedFilterPlaceholder = 'Filter...'; 58 | 59 | // event called when item or items from available items(left box) is selected 60 | @Output() onAvailableItemSelected: EventEmitter<{} | Array<{}>> = new EventEmitter<{} | Array<{}>>(); 61 | // event called when item or items from selected items(right box) is selected 62 | @Output() onSelectedItemsSelected: EventEmitter<{} | Array<{}>> = new EventEmitter<{} | Array<{}>>(); 63 | // event called when items are moved between boxes, returns state of both boxes and item moved 64 | @Output() onItemsMoved: EventEmitter = new EventEmitter(); 65 | 66 | // private variables to manage class 67 | searchTermAvailable = ''; 68 | searchTermSelected = ''; 69 | availableItems: Array = []; 70 | selectedItems: Array = []; 71 | listBoxForm: FormGroup; 72 | availableListBoxControl: FormControl = new FormControl(); 73 | selectedListBoxControl: FormControl = new FormControl(); 74 | availableSearchInputControl: FormControl = new FormControl(); 75 | selectedSearchInputControl: FormControl = new FormControl(); 76 | 77 | // control value accessors 78 | _onChange = (_: any) => { }; 79 | _onTouched = () => { }; 80 | 81 | constructor(public fb: FormBuilder) { 82 | 83 | this.listBoxForm = this.fb.group({ 84 | availableListBox: this.availableListBoxControl, 85 | selectedListBox: this.selectedListBoxControl, 86 | availableSearchInput: this.availableSearchInputControl, 87 | selectedSearchInput: this.selectedSearchInputControl 88 | }); 89 | } 90 | 91 | ngOnInit(): void { 92 | 93 | this.availableListBoxControl 94 | .valueChanges 95 | .subscribe((items: Array<{}>) => this.onAvailableItemSelected.emit(items)); 96 | this.selectedListBoxControl 97 | .valueChanges 98 | .subscribe((items: Array<{}>) => this.onSelectedItemsSelected.emit(items)); 99 | this.availableSearchInputControl 100 | .valueChanges 101 | .debounceTime(this.debounceTime) 102 | .distinctUntilChanged() 103 | .subscribe((search: string) => this.searchTermAvailable = search); 104 | this.selectedSearchInputControl 105 | .valueChanges 106 | .debounceTime(this.debounceTime) 107 | .distinctUntilChanged() 108 | .subscribe((search: string) => this.searchTermSelected = search); 109 | } 110 | 111 | /** 112 | * Move all items from available to selected 113 | */ 114 | moveAllItemsToSelected(): void { 115 | 116 | if (!this.availableItems.length) { 117 | return; 118 | } 119 | this.selectedItems = [...this.selectedItems, ...this.availableItems]; 120 | this.availableItems = []; 121 | this.onItemsMoved.emit({ 122 | available: this.availableItems, 123 | selected: this.selectedItems, 124 | movedItems: this.availableListBoxControl.value, 125 | from: 'available', 126 | to: 'selected' 127 | }); 128 | this.availableListBoxControl.setValue([]); 129 | this.writeValue(this.getValues()); 130 | } 131 | 132 | /** 133 | * Move all items from selected to available 134 | */ 135 | moveAllItemsToAvailable(): void { 136 | 137 | if (!this.selectedItems.length) { 138 | return; 139 | } 140 | this.availableItems = [...this.availableItems, ...this.selectedItems]; 141 | this.selectedItems = []; 142 | this.onItemsMoved.emit({ 143 | available: this.availableItems, 144 | selected: this.selectedItems, 145 | movedItems: this.selectedListBoxControl.value, 146 | from: 'selected', 147 | to: 'available' 148 | }); 149 | this.selectedListBoxControl.setValue([]); 150 | this.writeValue([]); 151 | } 152 | 153 | /** 154 | * Move marked items from available items to selected items 155 | */ 156 | moveMarkedAvailableItemsToSelected(): void { 157 | 158 | // first move items to selected 159 | this.selectedItems = [...this.selectedItems, 160 | ...intersectionwith(this.availableItems, this.availableListBoxControl.value, 161 | (item: IListBoxItem, value: string) => item.value === value)]; 162 | // now filter available items to not include marked values 163 | this.availableItems = [...differenceWith(this.availableItems, this.availableListBoxControl.value, 164 | (item: IListBoxItem, value: string) => item.value === value)]; 165 | // clear marked available items and emit event 166 | this.onItemsMoved.emit({ 167 | available: this.availableItems, 168 | selected: this.selectedItems, 169 | movedItems: this.availableListBoxControl.value, 170 | from: 'available', 171 | to: 'selected' 172 | }); 173 | this.availableListBoxControl.setValue([]); 174 | this.availableSearchInputControl.setValue(''); 175 | this.writeValue(this.getValues()); 176 | } 177 | 178 | /** 179 | * Move marked items from selected items to available items 180 | */ 181 | moveMarkedSelectedItemsToAvailable(): void { 182 | 183 | // first move items to available 184 | this.availableItems = [...this.availableItems, 185 | ...intersectionwith(this.selectedItems, this.selectedListBoxControl.value, 186 | (item: IListBoxItem, value: string) => item.value === value)]; 187 | // now filter available items to not include marked values 188 | this.selectedItems = [...differenceWith(this.selectedItems, this.selectedListBoxControl.value, 189 | (item: IListBoxItem, value: string) => item.value === value)]; 190 | // clear marked available items and emit event 191 | this.onItemsMoved.emit({ 192 | available: this.availableItems, 193 | selected: this.selectedItems, 194 | movedItems: this.selectedListBoxControl.value, 195 | from: 'selected', 196 | to: 'available' 197 | }); 198 | this.selectedListBoxControl.setValue([]); 199 | this.selectedSearchInputControl.setValue(''); 200 | this.writeValue(this.getValues()); 201 | } 202 | 203 | /** 204 | * Move single item from available to selected 205 | * @param item 206 | */ 207 | moveAvailableItemToSelected(item: IListBoxItem): void { 208 | 209 | this.availableItems = this.availableItems.filter((listItem: IListBoxItem) => listItem.value !== item.value); 210 | this.selectedItems = [...this.selectedItems, item]; 211 | this.onItemsMoved.emit({ 212 | available: this.availableItems, 213 | selected: this.selectedItems, 214 | movedItems: [item.value], 215 | from: 'available', 216 | to: 'selected' 217 | }); 218 | this.availableSearchInputControl.setValue(''); 219 | this.availableListBoxControl.setValue([]); 220 | this.writeValue(this.getValues()); 221 | } 222 | 223 | /** 224 | * Move single item from selected to available 225 | * @param item 226 | */ 227 | moveSelectedItemToAvailable(item: IListBoxItem): void { 228 | 229 | this.selectedItems = this.selectedItems.filter((listItem: IListBoxItem) => listItem.value !== item.value); 230 | this.availableItems = [...this.availableItems, item]; 231 | this.onItemsMoved.emit({ 232 | available: this.availableItems, 233 | selected: this.selectedItems, 234 | movedItems: [item.value], 235 | from: 'selected', 236 | to: 'available' 237 | }); 238 | this.selectedSearchInputControl.setValue(''); 239 | this.selectedListBoxControl.setValue([]); 240 | this.writeValue(this.getValues()); 241 | } 242 | 243 | /** 244 | * Function to pass to ngFor to improve performance, tracks items 245 | * by the value field 246 | * @param index 247 | * @param item 248 | */ 249 | trackByValue(index: number, item: {}): string { 250 | return item[this.valueField]; 251 | } 252 | 253 | /* Methods from ControlValueAccessor interface, required for ngModel and formControlName - begin */ 254 | writeValue(value: any): void { 255 | if (this.selectedItems && value && value.length > 0) { 256 | this.selectedItems = [...this.selectedItems, 257 | ...intersectionwith(this.availableItems, value, (item: IListBoxItem, val: string) => item.value === val)]; 258 | this.availableItems = [...differenceWith(this.availableItems, value, 259 | (item: IListBoxItem, val: string) => item.value === val)]; 260 | } 261 | this._onChange(value); 262 | } 263 | 264 | registerOnChange(fn: (_: any) => {}): void { 265 | this._onChange = fn; 266 | } 267 | 268 | registerOnTouched(fn: () => {}): void { 269 | this._onTouched = fn; 270 | } 271 | /* Methods from ControlValueAccessor interface, required for ngModel and formControlName - end */ 272 | 273 | /** 274 | * Utility method to get values from 275 | * selected items 276 | */ 277 | private getValues(): string[] { 278 | return (this.selectedItems || []).map((item: IListBoxItem) => item.value); 279 | } 280 | } 281 | -------------------------------------------------------------------------------- /src/tests/dual-list-box.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | import { CommonModule } from '@angular/common'; 3 | import { ReactiveFormsModule } from '@angular/forms'; 4 | import { DebugElement } from '@angular/core'; 5 | import { By } from '@angular/platform-browser'; 6 | import * as _ from 'lodash'; 7 | 8 | import { DualListBoxComponent } from '../dual-list-box.component'; 9 | import { ArraySortPipe, ArrayFilterPipe } from '../array.pipes'; 10 | import { IListBoxItem } from '../models'; 11 | 12 | describe('DualListBoxComponent with TCB', (): void => { 13 | 14 | let fixture: ComponentFixture; 15 | let testArray = []; 16 | for (let i = 1; i < 100; i++) { 17 | testArray.push({ 18 | id: i.toString(), 19 | name: 'Name ' + i 20 | }); 21 | } 22 | 23 | beforeEach((done: Function) => { 24 | TestBed.configureTestingModule({ 25 | declarations: [ 26 | DualListBoxComponent, 27 | ArraySortPipe, 28 | ArrayFilterPipe 29 | ], 30 | imports: [ 31 | CommonModule, 32 | ReactiveFormsModule 33 | ] 34 | }); 35 | 36 | TestBed.compileComponents().then(() => { 37 | fixture = TestBed.createComponent(DualListBoxComponent); 38 | done(); 39 | }); 40 | }); 41 | 42 | it('should initialize available and selected items from passed data, valueField and textField', (done: Function): void => { 43 | 44 | const component: DualListBoxComponent = fixture.componentInstance; 45 | component.valueField = 'id'; 46 | component.textField = 'name'; 47 | component.data = testArray; 48 | 49 | fixture.detectChanges(); 50 | 51 | expect(component.selectedItems).toEqual([]); 52 | expect(_.isEqualWith(testArray, component['availableItems'], (testItem, availableItem) => { 53 | return testItem.id === availableItem.value && testItem.name === availableItem.text; 54 | })).toBe(true); 55 | 56 | done(); 57 | }); 58 | 59 | it('should create options in available list box from passed data, valueField and textField', (done: Function): void => { 60 | 61 | const sortPipe: ArraySortPipe = new ArraySortPipe(); 62 | const component: DualListBoxComponent = fixture.componentInstance; 63 | component.valueField = 'id'; 64 | component.textField = 'name'; 65 | component.data = testArray; 66 | 67 | fixture.detectChanges(); 68 | 69 | const availableListBox: DebugElement = fixture.debugElement.query(By.css('select[formControlName=availableListBox]')); 70 | const selectedListBox: DebugElement = fixture.debugElement.query(By.css('select[formControlName=selectedListBox]')); 71 | expect(availableListBox).toBeTruthy(); 72 | expect(_.isEqual(sortPipe.transform(testArray, ['name', 'ASC']), availableListBox.children.map((elem: DebugElement) => ({ 73 | id: (elem.nativeElement as HTMLOptionElement).value.split(':')[1].trim().replace("'", '').replace("'", ''), 74 | name: (elem.nativeElement as HTMLOptionElement).text 75 | })))).toBe(true); 76 | expect(selectedListBox.children.length).toEqual(0); 77 | 78 | done(); 79 | }); 80 | 81 | it('should display default text above list boxes if none is passed', (done: Function): void => { 82 | 83 | const component: DualListBoxComponent = fixture.componentInstance; 84 | component.valueField = 'id'; 85 | component.textField = 'name'; 86 | component.data = testArray; 87 | 88 | fixture.detectChanges(); 89 | 90 | const boxTitles: string[] = fixture.debugElement.queryAll(By.css('.text-center.vertical-spacing-5')) 91 | .map((item: DebugElement) => (item.nativeElement as HTMLHeadingElement).innerHTML.toString()); 92 | 93 | expect(boxTitles.length).toEqual(2); 94 | expect(_.isEqual(boxTitles, [component.availableText, component.selectedText])).toBe(true); 95 | 96 | done(); 97 | }); 98 | 99 | it('should hide move all buttons if moveAllButton is false', (done: Function): void => { 100 | 101 | const component: DualListBoxComponent = fixture.componentInstance; 102 | component.valueField = 'id'; 103 | component.textField = 'name'; 104 | component.moveAllButton = false; 105 | component.data = testArray; 106 | 107 | fixture.detectChanges(); 108 | 109 | expect(fixture.debugElement.queryAll(By.css('.glyphicon.glyphicon-list')).length).toEqual(0); 110 | 111 | done(); 112 | }); 113 | 114 | // testing class methods 115 | 116 | it('should move all items from available to selected', (done: Function): void => { 117 | 118 | const component: DualListBoxComponent = fixture.componentInstance; 119 | component.valueField = 'id'; 120 | component.textField = 'name'; 121 | component.data = testArray; 122 | 123 | fixture.detectChanges(); 124 | 125 | expect(component.availableItems.length).toEqual(testArray.length); 126 | expect(component.selectedItems.length).toEqual(0); 127 | 128 | component.moveAllItemsToSelected(); 129 | 130 | expect(component.availableItems.length).toEqual(0); 131 | expect(component.selectedItems.length).toEqual(testArray.length); 132 | expect(_.isEqualWith(testArray, component['selectedItems'], (testItem, selectedItem) => { 133 | return testItem.id === selectedItem.value && testItem.name === selectedItem.text; 134 | })).toBe(true); 135 | expect(component.availableListBoxControl.value).toEqual([]); 136 | 137 | done(); 138 | }); 139 | 140 | it('should move all options[DOM] from available list box to selected list box', (done: Function): void => { 141 | 142 | const sortPipe: ArraySortPipe = new ArraySortPipe(); 143 | const component: DualListBoxComponent = fixture.componentInstance; 144 | component.valueField = 'id'; 145 | component.textField = 'name'; 146 | component.data = testArray; 147 | 148 | fixture.detectChanges(); 149 | 150 | expect(component.availableItems.length).toEqual(testArray.length); 151 | expect(component.selectedItems.length).toEqual(0); 152 | 153 | component.moveAllItemsToSelected(); 154 | 155 | fixture.detectChanges(); 156 | 157 | const availableListBox: DebugElement = fixture.debugElement.query(By.css('select[formControlName=availableListBox]')); 158 | const selectedListBox: DebugElement = fixture.debugElement.query(By.css('select[formControlName=selectedListBox]')); 159 | expect(availableListBox).toBeTruthy(); 160 | expect(selectedListBox).toBeTruthy(); 161 | expect(_.isEqual(sortPipe.transform(testArray, ['name', 'ASC']), selectedListBox.children.map((elem: DebugElement) => ({ 162 | id: (elem.nativeElement as HTMLOptionElement).value.split(':')[1].trim().replace("'", '').replace("'", ''), 163 | name: (elem.nativeElement as HTMLOptionElement).text 164 | })))).toBe(true); 165 | expect(availableListBox.children.length).toEqual(0); 166 | 167 | done(); 168 | }); 169 | 170 | it('should move all items from selected to available', (done: Function): void => { 171 | 172 | const component: DualListBoxComponent = fixture.componentInstance; 173 | component.valueField = 'id'; 174 | component.textField = 'name'; 175 | component.data = [...testArray]; 176 | 177 | fixture.detectChanges(); 178 | 179 | component.moveAllItemsToSelected(); 180 | 181 | expect(component.availableItems.length).toEqual(0); 182 | expect(component.selectedItems.length).toEqual(testArray.length); 183 | expect(_.isEqualWith(testArray, component.selectedItems, (testItem, selectedItem) => { 184 | return testItem.id === selectedItem.value && testItem.name === selectedItem.text; 185 | })).toBe(true); 186 | 187 | component.moveAllItemsToAvailable(); 188 | 189 | expect(component.selectedItems.length).toEqual(0); 190 | expect(component.availableItems.length).toEqual(testArray.length); 191 | expect(_.isEqualWith(testArray, component.availableItems, (testItem, selectedItem) => { 192 | return testItem.id === selectedItem.value && testItem.name === selectedItem.text; 193 | })).toBe(true); 194 | expect(component.selectedListBoxControl.value).toEqual([]); 195 | 196 | done(); 197 | }); 198 | 199 | it('should move all options[DOM] from selected list box to available list box', (done: Function): void => { 200 | 201 | const sortPipe: ArraySortPipe = new ArraySortPipe(); 202 | const component: DualListBoxComponent = fixture.componentInstance; 203 | component.valueField = 'id'; 204 | component.textField = 'name'; 205 | component.data = testArray; 206 | 207 | fixture.detectChanges(); 208 | 209 | expect(component.availableItems.length).toEqual(testArray.length); 210 | expect(component.selectedItems.length).toEqual(0); 211 | 212 | component.moveAllItemsToSelected(); 213 | 214 | fixture.detectChanges(); 215 | 216 | const availableListBox: DebugElement = fixture.debugElement.query(By.css('select[formControlName=availableListBox]')); 217 | const selectedListBox: DebugElement = fixture.debugElement.query(By.css('select[formControlName=selectedListBox]')); 218 | expect(availableListBox).toBeTruthy(); 219 | expect(selectedListBox).toBeTruthy(); 220 | expect(_.isEqual(sortPipe.transform(testArray, ['name', 'ASC']), selectedListBox.children.map((elem: DebugElement) => ({ 221 | id: (elem.nativeElement as HTMLOptionElement).value.split(':')[1].trim().replace("'", '').replace("'", ''), 222 | name: (elem.nativeElement as HTMLOptionElement).text 223 | })))).toBe(true); 224 | expect(availableListBox.children.length).toEqual(0); 225 | 226 | component.moveAllItemsToAvailable(); 227 | 228 | fixture.detectChanges(); 229 | 230 | expect(_.isEqual(sortPipe.transform(testArray, ['name', 'ASC']), availableListBox.children.map((elem: DebugElement) => ({ 231 | id: (elem.nativeElement as HTMLOptionElement).value.split(':')[1].trim().replace("'", '').replace("'", ''), 232 | name: (elem.nativeElement as HTMLOptionElement).text 233 | })))).toBe(true); 234 | expect(selectedListBox.children.length).toEqual(0); 235 | 236 | done(); 237 | }); 238 | 239 | it('should move all marked items from available to selected, reset search term and emit event', (done: Function): void => { 240 | 241 | const component: DualListBoxComponent = fixture.componentInstance; 242 | component.valueField = 'id'; 243 | component.textField = 'name'; 244 | component.data = testArray; 245 | spyOn(component.onItemsMoved, 'emit'); 246 | spyOn(component.onAvailableItemSelected, 'emit'); 247 | 248 | fixture.detectChanges(); 249 | 250 | const availableListBox: DebugElement = fixture.debugElement.query(By.css('select[formControlName=availableListBox]')); 251 | const selectedListBox: DebugElement = fixture.debugElement.query(By.css('select[formControlName=selectedListBox]')); 252 | let availableOptions: HTMLOptionElement[] = availableListBox.children 253 | .map((elem: DebugElement) => elem.nativeElement as HTMLOptionElement); 254 | let selectedOptions: HTMLOptionElement[] = selectedListBox.children 255 | .map((elem: DebugElement) => elem.nativeElement as HTMLOptionElement); 256 | 257 | const movedItems: any = []; 258 | for (let i = 0; i < 3; i++) { 259 | 260 | movedItems.push(availableOptions[i].value.split(':')[1].trim().replace("'", '').replace("'", '')); 261 | } 262 | component.availableListBoxControl.setValue(movedItems); 263 | expect(component.onAvailableItemSelected.emit).toHaveBeenCalledWith(movedItems); 264 | 265 | fixture.detectChanges(); 266 | 267 | component.moveMarkedAvailableItemsToSelected(); 268 | 269 | fixture.detectChanges(); 270 | 271 | availableOptions = availableListBox.children.map((elem: DebugElement) => elem.nativeElement as HTMLOptionElement); 272 | selectedOptions = selectedListBox.children.map((elem: DebugElement) => elem.nativeElement as HTMLOptionElement); 273 | 274 | expect(availableOptions.length).toEqual(testArray.length - 3); 275 | expect(selectedOptions.length).toEqual(3); 276 | expect(component['availableItems'].length).toEqual(testArray.length - 3); 277 | expect(component['selectedItems'].length).toEqual(3); 278 | 279 | expect(_.intersectionWith(availableOptions, movedItems, (option: HTMLOptionElement, item: { id: string, name: string }) => { 280 | return option.value.split(':')[1].trim().replace("'", '').replace("'", '') === item.id; 281 | }).length).toEqual(0); 282 | expect(_.intersectionWith(component.availableItems, component.selectedItems, 283 | (availableItem: IListBoxItem, selectedItem: IListBoxItem) => { 284 | return availableItem.value === selectedItem.value; 285 | }).length).toEqual(0); 286 | expect(component.availableListBoxControl.value).toEqual([]); 287 | expect(component.availableSearchInputControl.value).toEqual(''); 288 | expect(component.onItemsMoved.emit).toHaveBeenCalledWith({ 289 | available: component.availableItems, 290 | selected: component.selectedItems, 291 | movedItems: movedItems, 292 | from: 'available', 293 | to: 'selected' 294 | }); 295 | 296 | done(); 297 | }); 298 | 299 | it('should move all marked items from selected to available, reset search term and emit event', (done: Function): void => { 300 | 301 | const component: DualListBoxComponent = fixture.componentInstance; 302 | component.valueField = 'id'; 303 | component.textField = 'name'; 304 | component.data = testArray; 305 | spyOn(component.onItemsMoved, 'emit'); 306 | spyOn(component.onSelectedItemsSelected, 'emit'); 307 | 308 | fixture.detectChanges(); 309 | 310 | const availableListBox: DebugElement = fixture.debugElement.query(By.css('select[formControlName=availableListBox]')); 311 | const selectedListBox: DebugElement = fixture.debugElement.query(By.css('select[formControlName=selectedListBox]')); 312 | let availableOptions: HTMLOptionElement[] = availableListBox.children 313 | .map((elem: DebugElement) => elem.nativeElement as HTMLOptionElement); 314 | let selectedOptions: HTMLOptionElement[] = selectedListBox.children 315 | .map((elem: DebugElement) => elem.nativeElement as HTMLOptionElement); 316 | 317 | let movedItems: string[] = []; 318 | for (let i = 0; i < 3; i++) { 319 | 320 | movedItems.push(availableOptions[i].value.split(':')[1].trim().replace("'", '').replace("'", '')); 321 | } 322 | component.availableListBoxControl.setValue(movedItems); 323 | 324 | fixture.detectChanges(); 325 | 326 | component.moveMarkedAvailableItemsToSelected(); 327 | 328 | fixture.detectChanges(); 329 | 330 | availableOptions = availableListBox.children.map((elem: DebugElement) => elem.nativeElement as HTMLOptionElement); 331 | selectedOptions = selectedListBox.children.map((elem: DebugElement) => elem.nativeElement as HTMLOptionElement); 332 | 333 | movedItems = []; 334 | for (let i = 0; i < 3; i++) { 335 | 336 | movedItems.push(selectedOptions[i].value.split(':')[1].trim().replace("'", '').replace("'", '')); 337 | } 338 | component.selectedListBoxControl.setValue(movedItems); 339 | expect(component.onSelectedItemsSelected.emit).toHaveBeenCalledWith(movedItems); 340 | 341 | fixture.detectChanges(); 342 | 343 | component.moveMarkedSelectedItemsToAvailable(); 344 | 345 | fixture.detectChanges(); 346 | 347 | availableOptions = availableListBox.children.map((elem: DebugElement) => elem.nativeElement as HTMLOptionElement); 348 | selectedOptions = selectedListBox.children.map((elem: DebugElement) => elem.nativeElement as HTMLOptionElement); 349 | 350 | expect(availableOptions.length).toEqual(testArray.length); 351 | expect(selectedOptions.length).toEqual(0); 352 | expect(component.availableItems.length).toEqual(testArray.length); 353 | expect(component.selectedItems.length).toEqual(0); 354 | 355 | expect(_.intersectionWith(availableOptions, movedItems, (option: HTMLOptionElement, item: string) => { 356 | return option.value.split(':')[1].trim().replace("'", '').replace("'", '') === item; 357 | }).length).toEqual(3); 358 | expect(_.intersectionWith(component.availableItems, component.selectedItems, 359 | (availableItem: IListBoxItem, selectedItem: IListBoxItem) => { 360 | return availableItem.value === selectedItem.value; 361 | }).length).toEqual(0); 362 | expect(component.selectedListBoxControl.value).toEqual([]); 363 | expect(component.selectedSearchInputControl.value).toEqual(''); 364 | expect(component.onItemsMoved.emit).toHaveBeenCalledWith({ 365 | available: component.availableItems, 366 | selected: component.selectedItems, 367 | movedItems: movedItems, 368 | from: 'selected', 369 | to: 'available' 370 | }); 371 | 372 | done(); 373 | }); 374 | 375 | it('should move one item from available to selected, reset search term and emit event', (done: Function): void => { 376 | 377 | const component: DualListBoxComponent = fixture.componentInstance; 378 | component.valueField = 'id'; 379 | component.textField = 'name'; 380 | component.data = testArray; 381 | spyOn(component.onItemsMoved, 'emit'); 382 | 383 | component.moveAvailableItemToSelected({ value: '1', text: 'Name 1' }); 384 | 385 | expect(component.availableItems.length).toEqual(testArray.length - 1); 386 | expect(component.selectedItems.length).toEqual(1); 387 | expect(component.selectedItems[0].value).toEqual('1'); 388 | expect(component.availableSearchInputControl.value).toEqual(''); 389 | expect(component.onItemsMoved.emit).toHaveBeenCalledWith({ 390 | available: component.availableItems, 391 | selected: component.selectedItems, 392 | movedItems: ['1'], 393 | from: 'available', 394 | to: 'selected' 395 | }); 396 | 397 | fixture.detectChanges(); 398 | done(); 399 | }); 400 | 401 | it('should move one item from selected to available, reset search term and emit event', (done: Function): void => { 402 | 403 | const component: DualListBoxComponent = fixture.componentInstance; 404 | component.valueField = 'id'; 405 | component.textField = 'name'; 406 | component.data = testArray; 407 | spyOn(component.onItemsMoved, 'emit'); 408 | 409 | component.moveAvailableItemToSelected({ value: '1', text: 'Name 1' }); 410 | component.moveSelectedItemToAvailable({ value: '1', text: 'Name 1' }); 411 | 412 | expect(component.availableItems.length).toEqual(testArray.length ); 413 | expect(component.selectedItems.length).toEqual(0); 414 | expect(component.selectedSearchInputControl.value).toEqual(''); 415 | expect(component.onItemsMoved.emit).toHaveBeenCalledWith({ 416 | available: component.availableItems, 417 | selected: component.selectedItems, 418 | movedItems: ['1'], 419 | from: 'selected', 420 | to: 'available' 421 | }); 422 | 423 | fixture.detectChanges(); 424 | done(); 425 | }); 426 | }); --------------------------------------------------------------------------------