├── .gitignore ├── .travis.yml ├── Gruntfile.js ├── LICENSE ├── README.md ├── RELEASENOTES.md ├── bower.json ├── demo.gif ├── dist ├── ion-autocomplete.css ├── ion-autocomplete.js ├── ion-autocomplete.min.css └── ion-autocomplete.min.js ├── karma.conf.js ├── package.json ├── protractor-conf.js ├── src ├── ion-autocomplete.css └── ion-autocomplete.js └── test ├── e2e ├── ion-autocomplete.multiple-select.e2e.html ├── ion-autocomplete.multiple-select.e2e.spec.js ├── ion-autocomplete.prepopulated.e2e.html ├── ion-autocomplete.prepopulated.e2e.spec.js ├── ion-autocomplete.single-select.e2e.html └── ion-autocomplete.single-select.e2e.spec.js ├── ion-autocomplete.multiple-select.spec.js ├── ion-autocomplete.single-select.spec.js └── templates ├── test-template-data.html ├── test-template-dynamic.html └── test-template.html /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | bower_components 3 | npm-debug.log 4 | .idea 5 | *.iml 6 | build -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 5.5.0 3 | before_install: 4 | - export CHROME_BIN=chromium-browser 5 | - export DISPLAY=:99.0 6 | - sh -e /etc/init.d/xvfb start 7 | - npm install -g grunt-cli 8 | install: npm install 9 | env: 10 | global: 11 | - secure: P11PFMRl2C6GGp22pSsSntUjXYN4blW0Q4hXGKWirVoxoie0f+2b0zy0f3rb9z/nmtOHKTwxfJgmarFV5UAV2BgvyNN97TGHc8BufCcBhLZyZL+KNNHatxDnAuFXn1Xrofl/sFUygySN6KgUcEbhpP+wUn5QO5EAzIW/embbUQY= 12 | - secure: OtdVVdeoOgyUijyo0px2VlPb++6CY2NIZuyvNUiHTn3UTsjWxvcJmumGGXeyTYWebO71xxLssmOiVjRN8FoLWMgaMl7dBJCNL3E5dnnYS75ek5JBVlJ+AlY4mI5NkA+WAPKKwJCWrcNthHat+lZHyUstFhqxMndbgT/C3V98kUo= 13 | addons: 14 | sauce_connect: true 15 | hosts: ion-autocomplete 16 | after_script: cat ./build/coverage/**/lcov.info | ./node_modules/coveralls/bin/coveralls.js 17 | sudo: false -------------------------------------------------------------------------------- /Gruntfile.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function (grunt) { 4 | 5 | // load grunt tasks automatically 6 | require('load-grunt-tasks')(grunt); 7 | 8 | // add time used for tasks statistics 9 | require('time-grunt')(grunt); 10 | 11 | grunt.initConfig({ 12 | pkg: grunt.file.readJSON("package.json"), 13 | "bower-install-simple": { 14 | options: { 15 | color: true 16 | }, 17 | dev: { 18 | options: { 19 | production: false 20 | } 21 | } 22 | }, 23 | concat: { 24 | js: { 25 | options: { 26 | banner: "/*\n * <%= pkg.name %> <%= pkg.version %>\n * Copyright <%= grunt.template.today('yyyy') %> Danny Povolotski \n * Copyright modifications <%= grunt.template.today('yyyy') %> Guy Brand \n * https://github.com/guylabs/ion-autocomplete\n */\n(function() {\n\n'use strict';\n\n", 27 | footer: '\n})();', 28 | separator: '\n', 29 | process: true 30 | }, 31 | src: 'src/ion-autocomplete.js', 32 | dest: 'dist/<%= pkg.name %>.js' 33 | 34 | }, 35 | css: { 36 | src: 'src/ion-autocomplete.css', 37 | dest: 'dist/<%= pkg.name %>.css' 38 | } 39 | }, 40 | uglify: { 41 | options: { 42 | banner: "/*\n * <%= pkg.name %> <%= pkg.version %>\n * Copyright <%= grunt.template.today('yyyy') %> Danny Povolotski \n * Copyright modifications <%= grunt.template.today('yyyy') %> Guy Brand \n * https://github.com/guylabs/ion-autocomplete\n */\n" 43 | }, 44 | dist: { 45 | files: { 46 | 'dist/<%= pkg.name %>.min.js': ['<%= concat.js.dest %>'] 47 | } 48 | } 49 | }, 50 | cssmin: { 51 | target: { 52 | files: [{ 53 | expand: true, 54 | cwd: 'src', 55 | src: ['ion-autocomplete.css'], 56 | dest: 'dist', 57 | ext: '.min.css' 58 | }] 59 | } 60 | }, 61 | karma: { 62 | unit: { 63 | configFile: 'karma.conf.js' 64 | }, 65 | continuous: { 66 | configFile: 'karma.conf.js', 67 | singleRun: true 68 | } 69 | }, 70 | 'http-server': { 71 | dev: { 72 | runInBackground: true 73 | }, 74 | debug: { 75 | runInBackground: false 76 | } 77 | }, 78 | protractor: { 79 | options: { 80 | configFile: "protractor-conf.js" 81 | }, 82 | run: {} 83 | }, 84 | coveralls: { 85 | options: { 86 | src: 'coverage-results/lcov.info', 87 | force: true 88 | }, 89 | your_target: { 90 | src: 'coverage-results/extra-results-*.info' 91 | } 92 | } 93 | }); 94 | 95 | grunt.registerTask('build', ['bower-install-simple:dev', 'test', 'concat', 'uglify', 'cssmin']); 96 | grunt.registerTask('test', ['karma:continuous', 'http-server:dev', 'protractor:run']); 97 | grunt.registerTask('default', ['build']); 98 | 99 | // cannot use sauce labs with a pull requests 100 | if (parseInt(process.env.TRAVIS_PULL_REQUEST, 10) > 0) { 101 | grunt.registerTask('test', ['karma:continuous']); 102 | } 103 | 104 | }; 105 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Danny Povolotski 4 | Copyright (c) 2015-2017 Modifications by Guy Brand 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ion-autocomplete 2 | ================ 3 | [![Build Status](https://travis-ci.org/guylabs/ion-autocomplete.svg?branch=master)](https://travis-ci.org/guylabs/ion-autocomplete) 4 | [![Coverage Status](https://img.shields.io/coveralls/guylabs/ion-autocomplete.svg)](https://coveralls.io/r/guylabs/ion-autocomplete) 5 | [![Bower version](https://badge.fury.io/bo/ion-autocomplete.svg)](http://badge.fury.io/bo/ion-autocomplete) 6 | [![npm version](https://badge.fury.io/js/ion-autocomplete.svg)](http://badge.fury.io/js/ion-autocomplete) 7 | 8 | > Configurable Ionic directive for an autocomplete dropdown. 9 | 10 | :warning: Please follow the [Guidelines to report an issue](#guidelines-to-report-an-issue) 11 | 12 | #Table of contents 13 | 14 | - [Demo](#demo) 15 | - [Introduction](#introduction) 16 | - [Features](#features) 17 | - [Installation](#installation) 18 | - [Ionic compatibility](#ionic-compatibility) 19 | - [Usage](#usage) 20 | - [Configurable options](#configurable-options) 21 | - [The `items-method`](#the-items-method) 22 | - [The `items-method-value-key`](#the-items-method-value-key) 23 | - [The `item-value-key`](#the-item-value-key) 24 | - [The `item-view-value-key`](#the-item-view-value-key) 25 | - [The `max-selected-items`](#the-max-selected-items) 26 | - [The `items-clicked-method`](#the-items-clicked-method) 27 | - [The `items-removed-method`](#the-items-removed-method) 28 | - [External model](#external-model) 29 | - [The `model-to-item-method`](#the-model-to-item-method) 30 | - [The `cancel-button-clicked-method` (same as done button)](#the-cancel-button-clicked-method-same-as-done-button) 31 | - [ComponentId](#component-id) 32 | - [Placeholder](#placeholder) 33 | - [Cancel button label](#cancel-button-label) 34 | - [Select items label](#select-items-label) 35 | - [Selected items label](#selected-items-label) 36 | - [Template url](#template-url) 37 | - [Template data](#template-data) 38 | - [Loading icon](#loading-icon) 39 | - [Manage externally](#manage-externally) 40 | - [Selected items](#selected-items) 41 | - [Clear on select](#clear-on-select) 42 | - [Open and close CSS class ](#open-and-close-css-class) 43 | - [Using expressions in value keys](#using-expressions-in-value-keys) 44 | - [Debouncing](#debouncing) 45 | - [Usage inside an Ionic modal](#usage-inside-an-ionic-modal) 46 | - [Guidelines to report an issue](#guidelines-to-report-an-issue) 47 | - [Release notes](#release-notes) 48 | - [Acknowledgements](#acknowledgements) 49 | - [License](#license) 50 | 51 | # Demo 52 | 53 | You can find a live demo on [Codepen](http://codepen.io/guylabs/pen/GJmwMw) or see it in action in the following image: 54 | 55 | ![Animated demo](https://github.com/guylabs/ion-autocomplete/raw/master/demo.gif) 56 | 57 | # Introduction 58 | 59 | For one of my private projects I needed an autocomplete component in Ionic. I searched a lot and found some plain Angular autocompletes, but these had too much other dependencies and mostly didn't look that good within Ionic. Then one day I stumbled upon the [ion-google-place](https://github.com/israelidanny/ion-google-place) project which was exactly what I was looking for, except that it was just working with the Google Places API. So I forked the project and made it configurable such that you can add the service you need. The differences between the ion-google-place project and the ion-autocomplete are listed in the features. 60 | 61 | # Features 62 | 63 | The ion-autocomplete component has the following features: 64 | - Multiple selection support 65 | - Configurable service which provides the items to list 66 | - Allow to define the maximum number of selected items 67 | - Configure what is stored in the model and what is seen in the list 68 | - Configure the template used to show the autocomplete component 69 | - Configure a callback when an item is clicked/removed 70 | - Configure a callback when the cancel/done button is clicked 71 | - Configure all labels used in the component 72 | 73 | # Installation 74 | 75 | 1. Use bower to install the new module: 76 | ```bash 77 | bower install ion-autocomplete --save 78 | ``` 79 | 2. Import the `ion-autocomplete` javascript and css file into your HTML file: 80 | ```html 81 | 82 | 83 | ``` 84 | 3. Add `ion-autocomplete` as a dependency on your Ionic app: 85 | ```javascript 86 | angular.module('myApp', [ 87 | 'ionic', 88 | 'ion-autocomplete' 89 | ]); 90 | ``` 91 | 92 | # Ionic compatibility 93 | 94 | The ion-autocomplete component is running with the following Ionic versions: 95 | 96 | ion-autocomplete version | Ionic version 97 | ------------------------ | ------------- 98 | 0.0.2 - 0.1.2 | 1.0.0-beta.14 99 | 0.2.0 - 0.2.1 | 1.0.0-rc.3 100 | 0.2.2 - 0.2.3 | 1.0.0 101 | 0.3.0 - 0.3.1 | 1.1.0 102 | 0.3.2 - 0.3.3 | 1.1.1 103 | 0.4.0 - latest | 1.3.2 104 | 105 | # Usage 106 | 107 | To use the `ion-autocomplete` directive in single select mode you need set the `max-selected-items` attribute and add the following snippet to your template: 108 | ```html 109 | //usage with the attribute restriction 110 | 111 | ``` 112 | 113 | If you want to use it in multiple select mode you do not need to add anything special, just the following snippet to your template: 114 | ```html 115 | //usage with the attribute restriction 116 | 117 | ``` 118 | 119 | Check out the next chapter on how to configure the directive. 120 | 121 | ## Configurable options 122 | 123 | ### The `items-method` 124 | 125 | You are able to pass in a callback method which gets called when the user changes the value of the search input field. This is 126 | normally a call to the back end which retrieves the items for the specified query. Here is a small sample which will 127 | return a static item of the query: 128 | 129 | Define the callback in your scope: 130 | ```javascript 131 | $scope.callbackMethod = function (query, isInitializing) { 132 | return [query]; 133 | } 134 | ``` 135 | 136 | And set the items method on the directive: 137 | ```html 138 | 139 | ``` 140 | 141 | You are also able to return a promise from this callback method. For example: 142 | ```javascript 143 | $scope.callbackMethod = function (query, isInitializing) { 144 | return $http.get(endpoint); 145 | } 146 | ``` 147 | 148 | Note that the parameter for the `callbackMethod` needs to be named `query`. Otherwise the callback will not get called properly. 149 | If you want to also retrieve the [ComponentId](#component-id) then you need to add a second parameter called `componentId`: 150 | ```javascript 151 | $scope.callbackMethod = function (query, isInitializing, componentId) { 152 | if(componentId == "component1") { 153 | return $http.get(endpoint1); 154 | } 155 | return [query]; 156 | } 157 | ``` 158 | 159 | If you want to pre populate the items which are shown when the modal is visible before the user enters a query then you can check the `isInitializing` flag of 160 | the `items-method` as this is set to true if it is called for the initial items. Here is an example which shows the `test` item as an initial item: 161 | ```javascript 162 | $scope.callbackMethod = function (query, isInitializing) { 163 | if(isInitializing) { 164 | // depends on the configuration of the `items-method-value-key` (items) and the `item-value-key` (name) and `item-view-value-key` (name) 165 | return { items: [ { name: "test" } ] } 166 | } else { 167 | return $http.get(endpoint); 168 | } 169 | } 170 | ``` 171 | 172 | If you want to clear the list each time the user opens the modal then just return an empty array like in the following example: 173 | ```javascript 174 | $scope.callbackMethod = function (query, isInitializing) { 175 | if(isInitializing) { 176 | // depends on the configuration of the `items-method-value-key` (items) and the `item-value-key` (name) and `item-view-value-key` (name) 177 | return { items: [] } 178 | } else { 179 | return $http.get(endpoint); 180 | } 181 | } 182 | ``` 183 | 184 | And if you do not want that the searched items list gets modified then just return nothing as in this example: 185 | ```javascript 186 | $scope.callbackMethod = function (query, isInitializing) { 187 | if(!isInitializing) { 188 | return $http.get(endpoint); 189 | } 190 | } 191 | ``` 192 | 193 | A common usage for the `items-method` is to use the [Google Map Geocode API](https://developers.google.com/maps/documentation/geocoding/intro?hl=de#Geocoding) for address suggestions. 194 | 195 | To use Googles API you need to link the required library in your `index.html` file: 196 | ```html 197 | 198 | ``` 199 | 200 | In the `ion-autocomplete` input field you set the `items-method` to the below shown method `getAddressSuggestions` and set the value key to `formatted_address` to display the formatted address: 201 | ```html 202 | 205 | ``` 206 | 207 | To query Googles API you have to create a `Geocoder` instance and use the `queryString` as input and return the result object in a promise. 208 | ```javascript 209 | var geocoder = new google.maps.Geocoder(); 210 | 211 | $scope.getAddressSuggestions(queryString){ 212 | var defer = $q.defer(); 213 | geocoder.geocode( 214 | {address: queryString}, 215 | function (results, status) { 216 | if (status == google.maps.GeocoderStatus.OK) { defer.resolve(results); } 217 | else { defer.reject(results); } 218 | } 219 | ); 220 | return defer.promise; 221 | } 222 | ``` 223 | 224 | 225 | ### The `items-method-value-key` 226 | 227 | You are able to set the `items-method-value-key` attribute which maps to a value of the returned data of the `items-method`. If for 228 | example your callback method returns the following object: 229 | ```json 230 | { 231 | "items" : [ { 232 | "name" : "item1" 233 | },{ 234 | "name" : "item2" 235 | }, 236 | ... 237 | ] 238 | } 239 | ``` 240 | Then when you do not specify the `items-method-value-key` there will be no list displayed when you search for items in 241 | the search input field. You need to set the `items-method-value-key` to `items` such that the items are shown. If you right 242 | away return an array of items then you do not need to set the `items-method-value-key`. 243 | 244 | ### The `item-value-key` 245 | 246 | You are able to set the `item-value-key` attribute which maps to a value of the returned object from the `items-method`. The value 247 | is then saved in the defined `ng-model`. Here an example: 248 | 249 | The items method returns the following object: 250 | ```javascript 251 | [ 252 | { 253 | "id": "1", 254 | "name": "Item 1", 255 | ... 256 | } 257 | ... 258 | ] 259 | ``` 260 | 261 | And now you set the following `item-value-key`: 262 | ```html 263 | 264 | ``` 265 | 266 | Now when the user selects the `Item 1` from the list, then the value of the objects `id` is stored in the `ng-model`. If 267 | no `item-value-key` is passed into the directive, the whole item object will be stored in the `ng-model`. 268 | 269 | ### The `item-view-value-key` 270 | 271 | You are able to set the `item-view-value-key` attribute which maps to a value of the returned object from the `items-method`. The 272 | value is then showed in both input fields. Here an example: 273 | 274 | The `items-method` returns the following object: 275 | ```javascript 276 | [ 277 | { 278 | "id": "1", 279 | "name": "Item 1", 280 | ... 281 | } 282 | ... 283 | ] 284 | ``` 285 | 286 | And now you set the following `item-view-value-key`: 287 | ```html 288 | 289 | ``` 290 | 291 | Now when the user selects the `Item 1` from the list, then the value of the objects `name` is showed in both input fields. If 292 | no `item-view-value-key` is passed into the directive, the whole item object will be showed in both input fields. 293 | 294 | ### The `max-selected-items` 295 | 296 | You are able to set the `max-selected-items` attribute to any number to set the maximum selectable items inside the component. Here an example: 297 | ```html 298 | 299 | ``` 300 | 301 | Then the user is just able to select three items out of the returned items and also delete them again. The given `ng-model` is an 302 | array if multiple items are selected. 303 | 304 | You can also set a scope variable instead of a fixed value such that you can dynamically change the `max-selected-items` property according to your 305 | requirements. 306 | 307 | ### The `items-clicked-method` 308 | 309 | You are able to pass a function to the `items-clicked-method` attribute to be notified when an item is clicked. The name of the 310 | parameter of the function must be `callback`. Here is an example: 311 | 312 | Define the callback in your scope: 313 | ```javascript 314 | $scope.clickedMethod = function (callback) { 315 | // print out the selected item 316 | console.log(callback.item); 317 | 318 | // print out the component id 319 | console.log(callback.componentId); 320 | 321 | // print out the selected items if the multiple select flag is set to true and multiple elements are selected 322 | console.log(callback.selectedItems); 323 | 324 | // print out the selected items as an array 325 | console.log(callback.selectedItemsArray); 326 | } 327 | ``` 328 | 329 | And pass in the callback method in the directive: 330 | ```html 331 | 332 | ``` 333 | 334 | Then you get a callback object with the clicked/selected item and the selected items if you have multiple selected items (see [The `multiple-select`](#the-multiple-select)). 335 | 336 | ### The `items-removed-method` 337 | 338 | You are able to pass a function to the `items-removed-method` attribute to be notified when an item is removed from a multi-select list. The name of the 339 | parameter of the function must be `callback`. It is similar to items-clicked-method. This attribute has no defined behaviour for a single select list. 340 | 341 | Here is an example: 342 | 343 | Define the callback in your scope: 344 | ```javascript 345 | $scope.removedMethod = function (callback) { 346 | // print out the removed item 347 | console.log(callback.item); 348 | 349 | // print out the component id 350 | console.log(callback.componentId); 351 | 352 | // print out the selected items 353 | console.log(callback.selectedItems); 354 | 355 | // print out the selected items as an array 356 | console.log(callback.selectedItemsArray); 357 | } 358 | ``` 359 | 360 | And pass in the callback method in the directive: 361 | ```html 362 | 363 | ``` 364 | 365 | Then you get a callback object with the removed item and the selected items. 366 | 367 | ### External model 368 | 369 | The two way binded external model (`external-model` attribute on the component) is used to prepopulate the selected items with the model value. The [`model-to-item-method`](#the-model-to-item-method) is used to get the view item to the model and then the item is selected in the 370 | component. Be aware that the `external-model` is not updated by the component when an item is selected. It is just used to prepopulate or clear the selected items. If you need to get the current selected items you are able 371 | to read the value of the `ng-model`. For an example have a look at the [`model-to-item-method`](#the-model-to-item-method) documentation. 372 | 373 | If you need to clear the selected items then you are able to set the `external-model` to an empty array (another value is not clearing the selected items). 374 | 375 | ### The `model-to-item-method` 376 | 377 | This method is used if you want to prepopulate the model of the `ion-autocomplete` component. The [external model](#external-model) needs 378 | to have the same data as it would have when you select the items by hand. The component then takes the model values 379 | and calls the specified `model-to-item-method` to resolve the item from the back end and select it such that it is preselected. 380 | 381 | Here a small example: 382 | 383 | Define the `model-to-item-method` and `external-model` in your scope: 384 | ```javascript 385 | $scope.modelToItemMethod = function (modelValue) { 386 | 387 | // get the full model item from the model value and return it. You need to implement the `getModelItem` method by yourself 388 | // as this is just a sample. The method needs to retrieve the whole item (like the `items-method`) from just the model value. 389 | var modelItem = getModelItem(modelValue); 390 | return modelItem; 391 | } 392 | $scope.externalModel = ['test1', 'test2', 'test3']; 393 | ``` 394 | 395 | And set the `model-to-item-method` on the directive: 396 | ```html 397 | 398 | ``` 399 | 400 | You are also able to return a promise from this callback method. For example: 401 | ```javascript 402 | $scope.modelToItemMethod = function (modelValue) { 403 | return $http.get(endpoint + '?q=' + modelValue); 404 | } 405 | ``` 406 | 407 | Note that the parameter for the `model-to-item-method` needs to be named `modelValue`. Otherwise the callback will not get called properly. 408 | 409 | ### The `cancel-button-clicked-method` (same as done button) 410 | 411 | You are able to pass a function to the `cancel-button-clicked-method` attribute to be notified when the cancel/done button is clicked to close the modal. The name of the 412 | parameter of the function must be `callback`. Here is an example: 413 | 414 | Define the callback in your scope: 415 | ```javascript 416 | $scope.cancelButtonClickedMethod = function (callback) { 417 | // print out the component id 418 | console.log(callback.componentId); 419 | 420 | // print out the selected items 421 | console.log(callback.selectedItems); 422 | 423 | // print out the selected items as an array 424 | console.log(callback.selectedItemsArray); 425 | } 426 | ``` 427 | 428 | And pass in the callback method in the directive: 429 | ```html 430 | 431 | ``` 432 | 433 | Then you get a callback object with the selected items and the component id. 434 | 435 | ### Component Id 436 | 437 | The component id is an attribute on the `ion-autocomplete` component which sets a given id to the component. This id is then returned in 438 | the callback object of the [`items-clicked-method`](#the-items-clicked-method) and as a second parameter of the [`items-method`](#the-items-method). 439 | Here an example: 440 | ```html 441 | ` 442 | ``` 443 | 444 | You are able to set this is on each component if you have multiple components built up in a ng-repeat where you do not want to have multiple `items-method` 445 | for each component because you want to display other items in each component. You will also get it in the `items-clicked-method` callback object such that you just 446 | need to define one callback method and you can distinguish the calls with the `componentId` attribute right inside the method. 447 | 448 | ### Placeholder 449 | 450 | You are also able to set the placeholder on the input field and on the search input field if you add the `placeholder` 451 | attribute to the directive: 452 | ```html 453 | ` 454 | ``` 455 | 456 | ### Cancel button label 457 | 458 | You are also able to set the cancel button label (defaults to `Cancel`) if you add the `cancel-label` attribute to the directive: 459 | ```html 460 | ` 461 | ``` 462 | 463 | ### Select items label 464 | 465 | You are also able to set the select items label (defaults to `Select an item...`) if you add the `select-items-label` attribute to the directive: 466 | ```html 467 | ` 468 | ``` 469 | 470 | ### Selected items label 471 | 472 | You are also able to set the selected items label (defaults to `Selected items:`) if you add the `selected-items-label` attribute to the directive: 473 | ```html 474 | ` 475 | ``` 476 | 477 | ### Template url 478 | 479 | You are also able to set an own template for the autocomplete component (defaults to `''`) if you add the `template-url` attribute to the directive: 480 | ```html 481 | ` 482 | ``` 483 | 484 | This way you are able to override the default template (the `template` variable [here](https://github.com/guylabs/ion-autocomplete/blob/master/src/ion-autocomplete.js#L68)) 485 | and use your own template. The component will use the default template if the `template-url` is not defined. 486 | 487 | You are able to use all the configurable attributes as expressions in your template. I would advise to use the default template as base template 488 | and then add your custom additions to it. 489 | 490 | > Please also take care when you change how the items are shown or what method is called if an item is clicked, 491 | > because changing this could make the component unusable. 492 | 493 | You will need to set the proper `randomCssClass` for the outer most div container in your template and you can get the value by using the `{{viewModel.randomCssClass}}` expression 494 | like in the following example: 495 | 496 | ```html 497 | ' 105 | ].join(''); 106 | 107 | // load the template synchronously or asynchronously 108 | $q.when().then(function () { 109 | 110 | // first check if a template url is set and use this as template 111 | if (ionAutocompleteController.templateUrl) { 112 | return $templateRequest(ionAutocompleteController.templateUrl); 113 | } else { 114 | return template; 115 | } 116 | }).then(function (template) { 117 | 118 | // compile the template 119 | var searchInputElement = $compile(angular.element(template))(scope); 120 | 121 | // append the template to body 122 | $document.find('body').append(searchInputElement); 123 | 124 | 125 | // returns the value of an item 126 | ionAutocompleteController.getItemValue = function (item, key) { 127 | 128 | // if it's an array, go through all items and add the values to a new array and return it 129 | if (angular.isArray(item)) { 130 | var items = []; 131 | angular.forEach(item, function (itemValue) { 132 | if (key && angular.isObject(item)) { 133 | items.push($parse(key)(itemValue)); 134 | } else { 135 | items.push(itemValue); 136 | } 137 | }); 138 | return items; 139 | } else { 140 | if (key && angular.isObject(item)) { 141 | return $parse(key)(item); 142 | } 143 | } 144 | return item; 145 | }; 146 | 147 | // function which selects the item, hides the search container and the ionic backdrop if it has not maximum selected items attribute set 148 | ionAutocompleteController.selectItem = function (item) { 149 | 150 | // if the clear on select is true, clear the search query when an item is selected 151 | if (ionAutocompleteController.clearOnSelect == "true") { 152 | ionAutocompleteController.searchQuery = undefined; 153 | } 154 | 155 | // return if the max selected items is not equal to 1 and the maximum amount of selected items is reached 156 | if (ionAutocompleteController.maxSelectedItems != "1" && 157 | angular.isArray(ionAutocompleteController.selectedItems) && 158 | ionAutocompleteController.maxSelectedItems <= ionAutocompleteController.selectedItems.length) { 159 | return; 160 | } 161 | 162 | // store the selected items 163 | if (!isKeyValueInObjectArray(ionAutocompleteController.selectedItems, 164 | ionAutocompleteController.itemValueKey, ionAutocompleteController.getItemValue(item, ionAutocompleteController.itemValueKey))) { 165 | 166 | // if it is a single select set the item directly 167 | if (ionAutocompleteController.maxSelectedItems == "1") { 168 | ionAutocompleteController.selectedItems = item; 169 | } else { 170 | // create a new array to update the model. See https://github.com/angular-ui/ui-select/issues/191#issuecomment-55471732 171 | ionAutocompleteController.selectedItems = ionAutocompleteController.selectedItems.concat([item]); 172 | } 173 | } 174 | 175 | // set the view value and render it 176 | ngModelController.$setViewValue(ionAutocompleteController.selectedItems); 177 | ngModelController.$render(); 178 | 179 | // hide the container and the ionic backdrop if it is a single select to enhance usability 180 | if (ionAutocompleteController.maxSelectedItems == 1) { 181 | ionAutocompleteController.hideModal(); 182 | } 183 | 184 | // call items clicked callback 185 | if (angular.isDefined(attrs.itemsClickedMethod)) { 186 | ionAutocompleteController.itemsClickedMethod({ 187 | callback: { 188 | item: item, 189 | selectedItems: angular.isArray(ionAutocompleteController.selectedItems) ? ionAutocompleteController.selectedItems.slice() : ionAutocompleteController.selectedItems, 190 | selectedItemsArray: angular.isArray(ionAutocompleteController.selectedItems) ? ionAutocompleteController.selectedItems.slice() : [ionAutocompleteController.selectedItems], 191 | componentId: ionAutocompleteController.componentId 192 | } 193 | }); 194 | } 195 | }; 196 | 197 | // function which removes the item from the selected items. 198 | ionAutocompleteController.removeItem = function (index) { 199 | 200 | // clear the selected items if just one item is selected 201 | if (!angular.isArray(ionAutocompleteController.selectedItems)) { 202 | ionAutocompleteController.selectedItems = []; 203 | } else { 204 | // remove the item from the selected items and create a copy of the array to update the model. 205 | // See https://github.com/angular-ui/ui-select/issues/191#issuecomment-55471732 206 | var removed = ionAutocompleteController.selectedItems.splice(index, 1)[0]; 207 | ionAutocompleteController.selectedItems = ionAutocompleteController.selectedItems.slice(); 208 | } 209 | 210 | // set the view value and render it 211 | ngModelController.$setViewValue(ionAutocompleteController.selectedItems); 212 | ngModelController.$render(); 213 | 214 | // call items clicked callback 215 | if (angular.isDefined(attrs.itemsRemovedMethod)) { 216 | ionAutocompleteController.itemsRemovedMethod({ 217 | callback: { 218 | item: removed, 219 | selectedItems: angular.isArray(ionAutocompleteController.selectedItems) ? ionAutocompleteController.selectedItems.slice() : ionAutocompleteController.selectedItems, 220 | selectedItemsArray: angular.isArray(ionAutocompleteController.selectedItems) ? ionAutocompleteController.selectedItems.slice() : [ionAutocompleteController.selectedItems], 221 | componentId: ionAutocompleteController.componentId 222 | } 223 | }); 224 | } 225 | }; 226 | 227 | // watcher on the search field model to update the list according to the input 228 | scope.$watch('viewModel.searchQuery', function (query) { 229 | ionAutocompleteController.fetchSearchQuery(query, false); 230 | }); 231 | 232 | // watcher on the max selected items to update the selected items label 233 | scope.$watch('viewModel.maxSelectedItems', function (maxSelectedItems) { 234 | 235 | // only update the label if the value really changed 236 | if (ionAutocompleteController.maxSelectedItems != maxSelectedItems) { 237 | ionAutocompleteController.selectedItemsLabel = $interpolate("Selected items{{maxSelectedItems ? ' (max. ' + maxSelectedItems + ')' : ''}}:")(ionAutocompleteController); 238 | } 239 | }); 240 | 241 | // update the search items based on the returned value of the items-method 242 | ionAutocompleteController.fetchSearchQuery = function (query, isInitializing) { 243 | 244 | // right away return if the query is undefined to not call the items method for nothing 245 | if (query === undefined) { 246 | return; 247 | } 248 | 249 | if (angular.isDefined(attrs.itemsMethod)) { 250 | 251 | // show the loading icon 252 | ionAutocompleteController.showLoadingIcon = true; 253 | 254 | var queryObject = {query: query, isInitializing: isInitializing}; 255 | 256 | // if the component id is set, then add it to the query object 257 | if (ionAutocompleteController.componentId) { 258 | queryObject = { 259 | query: query, 260 | isInitializing: isInitializing, 261 | componentId: ionAutocompleteController.componentId 262 | } 263 | } 264 | 265 | // convert the given function to a $q promise to support promises too 266 | var promise = $q.when(ionAutocompleteController.itemsMethod(queryObject)); 267 | 268 | promise.then(function (promiseData) { 269 | 270 | // if the promise data is not set do nothing 271 | if (!promiseData) { 272 | return; 273 | } 274 | 275 | // if the given promise data object has a data property use this for the further processing as the 276 | // standard httpPromises from the $http functions store the response data in a data property 277 | if (promiseData && promiseData.data) { 278 | promiseData = promiseData.data; 279 | } 280 | 281 | // set the items which are returned by the items method 282 | ionAutocompleteController.searchItems = ionAutocompleteController.getItemValue(promiseData, 283 | ionAutocompleteController.itemsMethodValueKey); 284 | 285 | // force the collection repeat to redraw itself as there were issues when the first items were added 286 | $ionicScrollDelegate.resize(); 287 | }, function (error) { 288 | // reject the error because we do not handle the error here 289 | return $q.reject(error); 290 | }).finally(function () { 291 | // hide the loading icon 292 | ionAutocompleteController.showLoadingIcon = false; 293 | }); 294 | } 295 | }; 296 | 297 | var searchContainerDisplayed = false; 298 | 299 | ionAutocompleteController.showModal = function () { 300 | if (searchContainerDisplayed) { 301 | return; 302 | } 303 | 304 | // show the backdrop and the search container 305 | $ionicBackdrop.retain(); 306 | var modal = angular.element($document[0].querySelector('div.ion-autocomplete-container.' + ionAutocompleteController.randomCssClass)); 307 | modal.addClass(this.openClass); 308 | modal.removeClass(this.closeClass); 309 | 310 | // hide the container if the back button is pressed 311 | scope.$deregisterBackButton = $ionicPlatform.registerBackButtonAction(function () { 312 | ionAutocompleteController.hideModal(); 313 | }, 300); 314 | 315 | // get the compiled search field 316 | var searchInputElement = angular.element($document[0].querySelector('div.ion-autocomplete-container.' + ionAutocompleteController.randomCssClass + ' input:not(.no-autofocus)')); 317 | 318 | // focus on the search input field 319 | if (searchInputElement.length > 0) { 320 | searchInputElement[0].focus(); 321 | setTimeout(function () { 322 | searchInputElement[0].focus(); 323 | }, 100); 324 | } 325 | 326 | // force the collection repeat to redraw itself as there were issues when the first items were added 327 | $ionicScrollDelegate.resize(); 328 | 329 | searchContainerDisplayed = true; 330 | }; 331 | 332 | ionAutocompleteController.hideModal = function () { 333 | var modal = angular.element($document[0].querySelector('div.ion-autocomplete-container.' + ionAutocompleteController.randomCssClass)); 334 | modal.addClass(this.closeClass); 335 | modal.removeClass(this.openClass); 336 | ionAutocompleteController.searchQuery = undefined; 337 | $ionicBackdrop.release(); 338 | scope.$deregisterBackButton && scope.$deregisterBackButton(); 339 | searchContainerDisplayed = false; 340 | }; 341 | 342 | // object to store if the user moved the finger to prevent opening the modal 343 | var scrolling = { 344 | moved: false, 345 | startX: 0, 346 | startY: 0 347 | }; 348 | 349 | // store the start coordinates of the touch start event 350 | var onTouchStart = function (e) { 351 | scrolling.moved = false; 352 | // Use originalEvent when available, fix compatibility with jQuery 353 | if (typeof(e.originalEvent) !== 'undefined') { 354 | e = e.originalEvent; 355 | } 356 | scrolling.startX = e.touches[0].clientX; 357 | scrolling.startY = e.touches[0].clientY; 358 | }; 359 | 360 | // check if the finger moves more than 10px and set the moved flag to true 361 | var onTouchMove = function (e) { 362 | // Use originalEvent when available, fix compatibility with jQuery 363 | if (typeof(e.originalEvent) !== 'undefined') { 364 | e = e.originalEvent; 365 | } 366 | if (Math.abs(e.touches[0].clientX - scrolling.startX) > 10 || 367 | Math.abs(e.touches[0].clientY - scrolling.startY) > 10) { 368 | scrolling.moved = true; 369 | } 370 | }; 371 | 372 | // click handler on the input field to show the search container 373 | var onClick = function (event) { 374 | // only open the dialog if was not touched at the beginning of a legitimate scroll event 375 | if (scrolling.moved) { 376 | return; 377 | } 378 | 379 | // prevent the default event and the propagation 380 | event.preventDefault(); 381 | event.stopPropagation(); 382 | 383 | // call the fetch search query method once to be able to initialize it when the modal is shown 384 | // use an empty string to signal that there is no change in the search query 385 | ionAutocompleteController.fetchSearchQuery("", true); 386 | 387 | // show the ionic backdrop and the search container 388 | ionAutocompleteController.showModal(); 389 | }; 390 | 391 | var isKeyValueInObjectArray = function (objectArray, key, value) { 392 | if (angular.isArray(objectArray)) { 393 | for (var i = 0; i < objectArray.length; i++) { 394 | if (ionAutocompleteController.getItemValue(objectArray[i], key) === value) { 395 | return true; 396 | } 397 | } 398 | } 399 | return false; 400 | }; 401 | 402 | // function to call the model to item method and select the item 403 | var resolveAndSelectModelItem = function (modelValue) { 404 | // convert the given function to a $q promise to support promises too 405 | var promise = $q.when(ionAutocompleteController.modelToItemMethod({modelValue: modelValue})); 406 | 407 | promise.then(function (promiseData) { 408 | // select the item which are returned by the model to item method 409 | ionAutocompleteController.selectItem(promiseData); 410 | }, function (error) { 411 | // reject the error because we do not handle the error here 412 | return $q.reject(error); 413 | }); 414 | }; 415 | 416 | // if the click is not handled externally, bind the handlers to the click and touch events of the input field 417 | if (ionAutocompleteController.manageExternally == "false") { 418 | element.bind('touchstart', onTouchStart); 419 | element.bind('touchmove', onTouchMove); 420 | element.bind('touchend click focus', onClick); 421 | } 422 | 423 | // cancel handler for the cancel button which clears the search input field model and hides the 424 | // search container and the ionic backdrop and calls the cancel button clicked callback 425 | ionAutocompleteController.cancelClick = function () { 426 | ionAutocompleteController.hideModal(); 427 | 428 | // call cancel button clicked callback 429 | if (angular.isDefined(attrs.cancelButtonClickedMethod)) { 430 | ionAutocompleteController.cancelButtonClickedMethod({ 431 | callback: { 432 | selectedItems: angular.isArray(ionAutocompleteController.selectedItems) ? ionAutocompleteController.selectedItems.slice() : ionAutocompleteController.selectedItems, 433 | selectedItemsArray: angular.isArray(ionAutocompleteController.selectedItems) ? ionAutocompleteController.selectedItems.slice() : [ionAutocompleteController.selectedItems], 434 | componentId: ionAutocompleteController.componentId 435 | } 436 | }); 437 | } 438 | }; 439 | 440 | // watch the external model for changes and select the items inside the model 441 | scope.$watch("viewModel.externalModel", function (newModel) { 442 | 443 | if (angular.isArray(newModel) && newModel.length == 0) { 444 | // clear the selected items and set the view value and render it 445 | ionAutocompleteController.selectedItems = []; 446 | ngModelController.$setViewValue(ionAutocompleteController.selectedItems); 447 | ngModelController.$render(); 448 | return; 449 | } 450 | 451 | // prepopulate view and selected items if external model is already set 452 | if (newModel && angular.isDefined(attrs.modelToItemMethod)) { 453 | if (angular.isArray(newModel)) { 454 | ionAutocompleteController.selectedItems = []; 455 | angular.forEach(newModel, function (modelValue) { 456 | resolveAndSelectModelItem(modelValue); 457 | }) 458 | } else { 459 | resolveAndSelectModelItem(newModel); 460 | } 461 | } 462 | }); 463 | 464 | // remove the component from the dom when scope is getting destroyed 465 | scope.$on('$destroy', function () { 466 | $ionicBackdrop.release(); 467 | 468 | // angular takes care of cleaning all $watch's and listeners, but we still need to remove the modal 469 | searchInputElement.remove(); 470 | }); 471 | 472 | // render the view value of the model 473 | ngModelController.$render = function () { 474 | element.val(ionAutocompleteController.getItemValue(ngModelController.$viewValue, ionAutocompleteController.itemViewValueKey)); 475 | }; 476 | 477 | // set the view value of the model 478 | ngModelController.$formatters.push(function (modelValue) { 479 | var viewValue = ionAutocompleteController.getItemValue(modelValue, ionAutocompleteController.itemViewValueKey); 480 | return viewValue == undefined ? "" : viewValue; 481 | }); 482 | 483 | // set the model value of the model 484 | ngModelController.$parsers.push(function (viewValue) { 485 | return ionAutocompleteController.getItemValue(viewValue, ionAutocompleteController.itemValueKey); 486 | }); 487 | 488 | }); 489 | 490 | } 491 | }; 492 | } 493 | ]); 494 | -------------------------------------------------------------------------------- /test/e2e/ion-autocomplete.multiple-select.e2e.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | ion-autocomplete end to end test page 9 | 10 | 11 | 12 | 13 | 14 | 15 | 44 | 45 | 46 | 47 | 48 | 49 |
50 | 51 | 64 | 65 | 69 | 70 | 75 | 76 | 81 | 82 |
83 |
84 |
85 | 86 | 87 | -------------------------------------------------------------------------------- /test/e2e/ion-autocomplete.multiple-select.e2e.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | describe('ion-autocomplete multiple select', function () { 4 | 5 | var htmlFileName = 'ion-autocomplete.multiple-select.e2e.html'; 6 | 7 | it('must hide the search input field if the cancel button is pressed', function () { 8 | browser.get(htmlFileName); 9 | 10 | element(by.css('input.ion-autocomplete')).click().then(function () { 11 | expect($('input.ion-autocomplete-search').isDisplayed()).toBeTruthy(); 12 | 13 | element(by.css('button.ion-autocomplete-cancel')).click().then(function () { 14 | expect($('input.ion-autocomplete-search').isDisplayed()).toBeFalsy(); 15 | }); 16 | 17 | }); 18 | }); 19 | 20 | it('must show the list of found items if something is entered in the search', function () { 21 | browser.get(htmlFileName); 22 | 23 | element(by.css('input.ion-autocomplete')).click().then(function () { 24 | expect($('input.ion-autocomplete-search').isDisplayed()).toBeTruthy(); 25 | 26 | element(by.css('input.ion-autocomplete-search')).sendKeys("test"); 27 | 28 | var itemList = element.all(by.css('[ng-repeat="item in viewModel.searchItems track by $index"]')); 29 | expectCollectionRepeatCount(itemList, 3); 30 | expect(itemList.get(0).getText()).toEqual('view: test1'); 31 | expect(itemList.get(1).getText()).toEqual('view: test2'); 32 | expect(itemList.get(2).getText()).toEqual('view: test3'); 33 | 34 | }); 35 | }); 36 | 37 | it('must note hide the search input field if a item in the list is clicked and the item must be selected', function () { 38 | browser.get(htmlFileName); 39 | 40 | element(by.css('input.ion-autocomplete')).click().then(function () { 41 | expect($('input.ion-autocomplete-search').isDisplayed()).toBeTruthy(); 42 | 43 | element(by.css('input.ion-autocomplete-search')).sendKeys("test"); 44 | 45 | var itemList = element.all(by.css('[ng-repeat="item in viewModel.searchItems track by $index"]')); 46 | expectCollectionRepeatCount(itemList, 3); 47 | itemList.get(0).click().then(function () { 48 | expect($('input.ion-autocomplete-search').isDisplayed()).toBeTruthy(); 49 | expect($('input.ion-autocomplete-test-model').isDisplayed()).toBeTruthy(); 50 | expect($('input.ion-autocomplete-test-model').getAttribute('value')).toEqual('test1'); 51 | 52 | var selectedItemList = element.all(by.repeater('selectedItem in viewModel.selectedItems')); 53 | expect(selectedItemList.count()).toEqual(1); 54 | expect(selectedItemList.get(0).getText()).toEqual('view: test1'); 55 | }) 56 | 57 | }); 58 | }); 59 | 60 | it('must not be able to add an item twice to the selected items', function () { 61 | browser.get(htmlFileName); 62 | 63 | element(by.css('input.ion-autocomplete')).click().then(function () { 64 | expect($('input.ion-autocomplete-search').isDisplayed()).toBeTruthy(); 65 | 66 | element(by.css('input.ion-autocomplete-search')).sendKeys("test"); 67 | 68 | var itemList = element.all(by.css('[ng-repeat="item in viewModel.searchItems track by $index"]')); 69 | expectCollectionRepeatCount(itemList, 3); 70 | itemList.get(0).click().then(function () { 71 | var selectedItemList = element.all(by.repeater('selectedItem in viewModel.selectedItems')); 72 | expect(selectedItemList.count()).toEqual(1); 73 | expect(selectedItemList.get(0).getText()).toEqual('view: test1'); 74 | expect($('input.ion-autocomplete-test-model').getAttribute('value')).toEqual('test1'); 75 | 76 | element(by.css('input.ion-autocomplete-search')).sendKeys("test").then(function () { 77 | var itemList = element.all(by.css('[ng-repeat="item in viewModel.searchItems track by $index"]')); 78 | 79 | // get the fourth element as this one is the one that is shown in the collection repeat 80 | itemList.get(0).click().then(function () { 81 | var selectedItemList = element.all(by.repeater('selectedItem in viewModel.selectedItems')); 82 | expect(selectedItemList.count()).toEqual(1); 83 | expect(selectedItemList.get(0).getText()).toEqual('view: test1'); 84 | expect($('input.ion-autocomplete-test-model').getAttribute('value')).toEqual('test1'); 85 | }); 86 | }); 87 | }) 88 | 89 | }); 90 | }); 91 | 92 | it('must be able to delete an item if the delete button is clicked along with callbacks in both directions', function () { 93 | browser.get(htmlFileName); 94 | 95 | expect($('input.ion-autocomplete-clicked-model').evaluate('clickedValueModel')).toEqual(''); 96 | expect($('input.ion-autocomplete-removed-model').evaluate('removedValueModel')).toEqual(''); 97 | 98 | element(by.css('input.ion-autocomplete')).click().then(function () { 99 | expect($('input.ion-autocomplete-search').isDisplayed()).toBeTruthy(); 100 | 101 | element(by.css('input.ion-autocomplete-search')).sendKeys("test"); 102 | 103 | // select first item 104 | var itemList = element.all(by.css('[ng-repeat="item in viewModel.searchItems track by $index"]')); 105 | expectCollectionRepeatCount(itemList, 3); 106 | itemList.get(0).click().then(function () { 107 | var selectedItemList = element.all(by.repeater('selectedItem in viewModel.selectedItems')); 108 | expect(selectedItemList.count()).toEqual(1); 109 | expect(selectedItemList.get(0).getText()).toEqual('view: test1'); 110 | expect($('input.ion-autocomplete-test-model').getAttribute('value')).toEqual('test1'); 111 | expect($('input.ion-autocomplete-removed-model').evaluate('removedValueModel')).toEqual(''); 112 | element(by.css('input.ion-autocomplete-clicked-model')).evaluate('clickedValueModel').then(function (clickedModelValue) { 113 | expect(clickedModelValue.item.name).toEqual('test1'); 114 | expect(clickedModelValue.selectedItems.length).toEqual(1); 115 | expect(clickedModelValue.selectedItems[0].name).toEqual('test1'); 116 | expect(clickedModelValue.selectedItemsArray).toEqual(clickedModelValue.selectedItems); 117 | }); 118 | 119 | // select second item 120 | element(by.css('input.ion-autocomplete-search')).sendKeys("test"); 121 | var itemList = element.all(by.css('[ng-repeat="item in viewModel.searchItems track by $index"]')); 122 | 123 | // get the fifth element as this one is the one that is shown in the collection repeat 124 | itemList.get(1).click().then(function () { 125 | var selectedItemList = element.all(by.repeater('selectedItem in viewModel.selectedItems')); 126 | expect(selectedItemList.count()).toEqual(2); 127 | expect(selectedItemList.get(0).getText()).toEqual('view: test1'); 128 | expect(selectedItemList.get(1).getText()).toEqual('view: test2'); 129 | expect($('input.ion-autocomplete-test-model').getAttribute('value')).toEqual('test1,test2'); 130 | expect($('input.ion-autocomplete-removed-model').evaluate('removedValueModel')).toEqual(''); 131 | element(by.css('input.ion-autocomplete-clicked-model')).evaluate('clickedValueModel').then(function (clickedModelValue) { 132 | expect(clickedModelValue.item.name).toEqual('test2'); 133 | expect(clickedModelValue.selectedItems.length).toEqual(2); 134 | expect(clickedModelValue.selectedItems[0].name).toEqual('test1'); 135 | expect(clickedModelValue.selectedItems[1].name).toEqual('test2'); 136 | expect(clickedModelValue.selectedItemsArray).toEqual(clickedModelValue.selectedItems); 137 | }); 138 | 139 | // select third item 140 | element(by.css('input.ion-autocomplete-search')).sendKeys("test"); 141 | var itemList = element.all(by.css('[ng-repeat="item in viewModel.searchItems track by $index"]')); 142 | 143 | // get the eighth element as this one is the one that is shown in the collection repeat 144 | itemList.get(2).click().then(function () { 145 | var selectedItemList = element.all(by.repeater('selectedItem in viewModel.selectedItems')); 146 | expect(selectedItemList.count()).toEqual(3); 147 | expect(selectedItemList.get(0).getText()).toEqual('view: test1'); 148 | expect(selectedItemList.get(1).getText()).toEqual('view: test2'); 149 | expect(selectedItemList.get(2).getText()).toEqual('view: test3'); 150 | expect($('input.ion-autocomplete-test-model').getAttribute('value')).toEqual('test1,test2,test3'); 151 | expect($('input.ion-autocomplete-removed-model').evaluate('removedValueModel')).toEqual(''); 152 | element(by.css('input.ion-autocomplete-clicked-model')).evaluate('clickedValueModel').then(function (clickedModelValue) { 153 | expect(clickedModelValue.item.name).toEqual('test3'); 154 | expect(clickedModelValue.selectedItems.length).toEqual(3); 155 | expect(clickedModelValue.selectedItems[0].name).toEqual('test1'); 156 | expect(clickedModelValue.selectedItems[1].name).toEqual('test2'); 157 | expect(clickedModelValue.selectedItems[2].name).toEqual('test3'); 158 | expect(clickedModelValue.selectedItemsArray).toEqual(clickedModelValue.selectedItems); 159 | }); 160 | 161 | // delete the item from the selected items 162 | selectedItemList.get(1).element(by.css('[ng-click="viewModel.removeItem($index)"]')).click().then(function () { 163 | var selectedItemList = element.all(by.repeater('selectedItem in viewModel.selectedItems')); 164 | expect(selectedItemList.count()).toEqual(2); 165 | expect(selectedItemList.get(0).getText()).toEqual('view: test1'); 166 | expect(selectedItemList.get(1).getText()).toEqual('view: test3'); 167 | expect($('input.ion-autocomplete-test-model').getAttribute('value')).toEqual('test1,test3'); 168 | }); 169 | 170 | element(by.css('input.ion-autocomplete-clicked-model')).evaluate('clickedValueModel').then(function (clickedModelValue) { 171 | // Showing result of final clicked callback 172 | expect(clickedModelValue.item.name).toEqual('test3'); 173 | expect(clickedModelValue.selectedItems.length).toEqual(3); 174 | expect(clickedModelValue.selectedItems[0].name).toEqual('test1'); 175 | expect(clickedModelValue.selectedItems[1].name).toEqual('test2'); 176 | expect(clickedModelValue.selectedItems[2].name).toEqual('test3'); 177 | expect(clickedModelValue.selectedItemsArray).toEqual(clickedModelValue.selectedItems); 178 | }); 179 | element(by.css('input.ion-autocomplete-removed-model')).evaluate('removedValueModel').then(function (removedValueModel) { 180 | // Showing result of final removed callback 181 | expect(removedValueModel.item.name).toEqual('test2'); 182 | expect(removedValueModel.selectedItems.length).toEqual(2); 183 | expect(removedValueModel.selectedItems[0].name).toEqual('test1'); 184 | expect(removedValueModel.selectedItems[1].name).toEqual('test3'); 185 | expect(removedValueModel.selectedItemsArray).toEqual(removedValueModel.selectedItems); 186 | }); 187 | }); 188 | 189 | }); 190 | }) 191 | 192 | }); 193 | }); 194 | 195 | it('must call the items clicked method if an item is clicked', function () { 196 | browser.get(htmlFileName); 197 | 198 | expect($('input.ion-autocomplete-clicked-model').evaluate('clickedValueModel')).toEqual(''); 199 | expect($('input.ion-autocomplete-removed-model').evaluate('removedValueModel')).toEqual(''); 200 | 201 | element(by.css('input.ion-autocomplete')).click().then(function () { 202 | expect($('input.ion-autocomplete-search').isDisplayed()).toBeTruthy(); 203 | 204 | element(by.css('input.ion-autocomplete-search')).sendKeys("test"); 205 | 206 | var itemList = element.all(by.css('[ng-repeat="item in viewModel.searchItems track by $index"]')); 207 | expectCollectionRepeatCount(itemList, 3); 208 | itemList.get(0).click().then(function () { 209 | expect($('input.ion-autocomplete-search').isDisplayed()).toBeTruthy(); 210 | expect($('input.ion-autocomplete-test-model').isDisplayed()).toBeTruthy(); 211 | expect($('input.ion-autocomplete-test-model').getAttribute('value')).toEqual('test1'); 212 | 213 | // select second item 214 | element(by.css('input.ion-autocomplete-search')).sendKeys("test"); 215 | var itemList = element.all(by.css('[ng-repeat="item in viewModel.searchItems track by $index"]')); 216 | 217 | // get the fifth element as this one is the one that is shown in the collection repeat 218 | itemList.get(1).click().then(function () { 219 | var selectedItemList = element.all(by.repeater('selectedItem in viewModel.selectedItems')); 220 | expect(selectedItemList.count()).toEqual(2); 221 | expect(selectedItemList.get(0).getText()).toEqual('view: test1'); 222 | expect(selectedItemList.get(1).getText()).toEqual('view: test2'); 223 | expect($('input.ion-autocomplete-test-model').getAttribute('value')).toEqual('test1,test2'); 224 | 225 | // expect the callback value 226 | element(by.css('input.ion-autocomplete-clicked-model')).evaluate('clickedValueModel').then(function (clickedModelValue) { 227 | expect(clickedModelValue.item.name).toEqual('test2'); 228 | expect(clickedModelValue.selectedItems.length).toEqual(2); 229 | expect(clickedModelValue.selectedItems[0].name).toEqual('test1'); 230 | expect(clickedModelValue.selectedItems[1].name).toEqual('test2'); 231 | expect(clickedModelValue.selectedItemsArray).toEqual(clickedModelValue.selectedItems); 232 | }); 233 | expect($('input.ion-autocomplete-removed-model').evaluate('removedValueModel')).toEqual(''); 234 | 235 | }); 236 | 237 | }) 238 | 239 | }); 240 | }); 241 | 242 | it('must not be able to select more items as the max selected items attribute allows', function () { 243 | browser.get(htmlFileName); 244 | 245 | element(by.css('input.ion-autocomplete')).click().then(function () { 246 | expect($('input.ion-autocomplete-search').isDisplayed()).toBeTruthy(); 247 | 248 | element(by.css('input.ion-autocomplete-search')).sendKeys("test"); 249 | 250 | // select the first item 251 | var itemList = element.all(by.css('[ng-repeat="item in viewModel.searchItems track by $index"]')); 252 | expectCollectionRepeatCount(itemList, 3); 253 | itemList.get(0).click().then(function () { 254 | expect($('input.ion-autocomplete-test-model').getAttribute('value')).toEqual('test1'); 255 | 256 | // select the second item 257 | element(by.css('input.ion-autocomplete-search')).sendKeys("test"); 258 | var itemList = element.all(by.css('[ng-repeat="item in viewModel.searchItems track by $index"]')); 259 | itemList.get(1).click().then(function () { 260 | expect($('input.ion-autocomplete-test-model').getAttribute('value')).toEqual('test1,test2'); 261 | 262 | // select the third item 263 | element(by.css('input.ion-autocomplete-search')).sendKeys("test"); 264 | var itemList = element.all(by.css('[ng-repeat="item in viewModel.searchItems track by $index"]')); 265 | itemList.get(2).click().then(function () { 266 | expect($('input.ion-autocomplete-test-model').getAttribute('value')).toEqual('test1,test2,test3'); 267 | 268 | // try to select the fourth item 269 | element(by.css('input.ion-autocomplete-search')).sendKeys("test1"); 270 | var itemList = element.all(by.css('[ng-repeat="item in viewModel.searchItems track by $index"]')); 271 | itemList.get(0).click().then(function () { 272 | expect($('input.ion-autocomplete-test-model').getAttribute('value')).toEqual('test1,test2,test3'); 273 | }); 274 | }); 275 | }); 276 | }); 277 | }); 278 | }); 279 | 280 | function expectCollectionRepeatCount(items, count) { 281 | for (var i = 0; i < count; i++) { 282 | expect(items.get(i).getText().isDisplayed()).toBeTruthy(); 283 | } 284 | } 285 | 286 | }); -------------------------------------------------------------------------------- /test/e2e/ion-autocomplete.prepopulated.e2e.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | ion-autocomplete end to end test page 9 | 10 | 11 | 12 | 13 | 14 | 15 | 58 | 59 | 60 | 61 | 62 | 63 |
64 | 65 | 80 | 81 | 85 | 86 | 91 | 92 | 97 | 98 | 103 |
104 | 107 |
108 |
109 | 110 | 111 | -------------------------------------------------------------------------------- /test/e2e/ion-autocomplete.prepopulated.e2e.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | describe('ion-autocomplete multiple select', function () { 4 | 5 | var htmlFileName = 'ion-autocomplete.prepopulated.e2e.html'; 6 | 7 | it('must prepopulate the model and show the proper values', function () { 8 | browser.get(htmlFileName); 9 | 10 | expect($('input.ion-autocomplete-test-model').isDisplayed()).toBeTruthy(); 11 | expect($('input.ion-autocomplete-test-model').getAttribute('value')).toEqual('test1,test2'); 12 | 13 | expect($('input.ion-autocomplete-external-model').getAttribute('value')).toEqual('test1,test2'); 14 | 15 | element(by.css('input.ion-autocomplete')).click().then(function () { 16 | expect($('input.ion-autocomplete-search').isDisplayed()).toBeTruthy(); 17 | 18 | var selectedItemList = element.all(by.repeater('selectedItem in viewModel.selectedItems')); 19 | expect(selectedItemList.count()).toEqual(2); 20 | expect(selectedItemList.get(0).getText()).toEqual('view: test1'); 21 | expect(selectedItemList.get(1).getText()).toEqual('view: test2'); 22 | 23 | 24 | }); 25 | }); 26 | 27 | it('must clear the model and do not show any selected items anymore', function () { 28 | browser.get(htmlFileName); 29 | 30 | expect($('input.ion-autocomplete-test-model').isDisplayed()).toBeTruthy(); 31 | expect($('input.ion-autocomplete-test-model').getAttribute('value')).toEqual('test1,test2'); 32 | 33 | expect($('input.ion-autocomplete-external-model').getAttribute('value')).toEqual('test1,test2'); 34 | 35 | element(by.css('input.ion-autocomplete')).click().then(function () { 36 | expect($('input.ion-autocomplete-search').isDisplayed()).toBeTruthy(); 37 | 38 | var selectedItemList = element.all(by.repeater('selectedItem in viewModel.selectedItems')); 39 | expect(selectedItemList.count()).toEqual(2); 40 | expect(selectedItemList.get(0).getText()).toEqual('view: test1'); 41 | expect(selectedItemList.get(1).getText()).toEqual('view: test2'); 42 | 43 | element(by.css('button.ion-autocomplete-cancel')).click().then(function () { 44 | expect($('input.ion-autocomplete-search').isDisplayed()).toBeFalsy(); 45 | 46 | // click the button to clear the items 47 | element(by.css('button.ion-autocomplete-clear-button')).click().then(function () { 48 | expect($('input.ion-autocomplete-test-model').getAttribute('value')).toEqual(''); 49 | expect($('input.ion-autocomplete-external-model').getAttribute('value')).toEqual(''); 50 | }); 51 | }); 52 | }); 53 | }); 54 | 55 | }); -------------------------------------------------------------------------------- /test/e2e/ion-autocomplete.single-select.e2e.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | ion-autocomplete end to end test page 9 | 10 | 11 | 12 | 13 | 14 | 15 | 44 | 54 | 55 | 56 | 57 | 58 | 59 |
60 | 61 | 77 | 78 | 82 | 83 | 88 | 89 | 94 | 95 |
96 |
97 |
98 | 99 | -------------------------------------------------------------------------------- /test/e2e/ion-autocomplete.single-select.e2e.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | describe('ion-autocomplete single select', function () { 4 | 5 | var htmlFileName = 'ion-autocomplete.single-select.e2e.html'; 6 | 7 | it('must not show the search input field by default', function () { 8 | browser.get(htmlFileName); 9 | expect($('input.ion-autocomplete-search').isDisplayed()).toBeFalsy(); 10 | }); 11 | 12 | it('must show the search input field if the input field is clicked', function () { 13 | browser.get(htmlFileName); 14 | element(by.css('input.ion-autocomplete')).click().then(function () { 15 | expect($('input.ion-autocomplete-search').isDisplayed()).toBeTruthy(); 16 | }); 17 | }); 18 | 19 | it('must hide the search input field if the cancel button is pressed', function () { 20 | browser.get(htmlFileName); 21 | 22 | element(by.css('input.ion-autocomplete')).click().then(function () { 23 | expect($('input.ion-autocomplete-search').isDisplayed()).toBeTruthy(); 24 | 25 | element(by.css('button.ion-autocomplete-cancel')).click().then(function () { 26 | expect($('input.ion-autocomplete-search').isDisplayed()).toBeFalsy(); 27 | }); 28 | 29 | }); 30 | }); 31 | 32 | it('must show the list of found items if something is entered in the search', function () { 33 | browser.get(htmlFileName); 34 | 35 | element(by.css('input.ion-autocomplete')).click().then(function () { 36 | expect($('input.ion-autocomplete-search').isDisplayed()).toBeTruthy(); 37 | 38 | element(by.css('input.ion-autocomplete-search')).sendKeys("test"); 39 | 40 | var itemList = element.all(by.css('[ng-repeat="item in viewModel.searchItems track by $index"]')); 41 | expectCollectionRepeatCount(itemList, 3); 42 | expect(itemList.get(0).getText()).toEqual('view: test1'); 43 | expect(itemList.get(1).getText()).toEqual('view: test2'); 44 | expect(itemList.get(2).getText()).toEqual('view: test3'); 45 | 46 | }); 47 | }); 48 | 49 | it('must show the prepopulated list of search items if this is set externally', function () { 50 | browser.get(htmlFileName); 51 | 52 | element(by.css('input.ion-autocomplete')).click().then(function () { 53 | expect($('input.ion-autocomplete-search').isDisplayed()).toBeTruthy(); 54 | 55 | element(by.css('input.ion-autocomplete-search')).sendKeys("test"); 56 | 57 | var itemList = element.all(by.css('[ng-repeat="item in viewModel.searchItems track by $index"]')); 58 | expectCollectionRepeatCount(itemList, 3); 59 | expect(itemList.get(0).getText()).toEqual('view: test1'); 60 | expect(itemList.get(1).getText()).toEqual('view: test2'); 61 | expect(itemList.get(2).getText()).toEqual('view: test3'); 62 | 63 | }); 64 | }); 65 | 66 | it('must hide the search input field if a item in the list is clicked', function () { 67 | browser.get(htmlFileName); 68 | 69 | element(by.css('input.ion-autocomplete')).click().then(function () { 70 | expect($('input.ion-autocomplete-search').isDisplayed()).toBeTruthy(); 71 | 72 | element(by.css('input.ion-autocomplete-search')).sendKeys("test"); 73 | 74 | var itemList = element.all(by.css('[ng-repeat="item in viewModel.searchItems track by $index"]')); 75 | expectCollectionRepeatCount(itemList, 3); 76 | itemList.get(0).click().then(function () { 77 | expect($('input.ion-autocomplete-search').isDisplayed()).toBeFalsy(); 78 | expect($('input.ion-autocomplete-test-model').isDisplayed()).toBeTruthy(); 79 | expect($('input.ion-autocomplete-test-model').getAttribute('value')).toEqual('test1'); 80 | }) 81 | 82 | }); 83 | }); 84 | 85 | it('must call the items clicked method if an item is clicked', function () { 86 | browser.get(htmlFileName); 87 | 88 | expect($('input.ion-autocomplete-callback-model').evaluate('callbackValueModel')).toEqual(''); 89 | 90 | element(by.css('input.ion-autocomplete')).click().then(function () { 91 | expect($('input.ion-autocomplete-search').isDisplayed()).toBeTruthy(); 92 | 93 | element(by.css('input.ion-autocomplete-search')).sendKeys("test"); 94 | 95 | var itemList = element.all(by.css('[ng-repeat="item in viewModel.searchItems track by $index"]')); 96 | expectCollectionRepeatCount(itemList, 3); 97 | itemList.get(0).click().then(function () { 98 | expect($('input.ion-autocomplete-search').isDisplayed()).toBeFalsy(); 99 | expect($('input.ion-autocomplete-test-model').isDisplayed()).toBeTruthy(); 100 | expect($('input.ion-autocomplete-test-model').getAttribute('value')).toEqual('test1'); 101 | 102 | // expect the callback value 103 | element(by.css('input.ion-autocomplete-callback-model')).evaluate('callbackValueModel').then(function (callbackModelValue) { 104 | expect(callbackModelValue.item.name).toEqual('test1'); 105 | expect(callbackModelValue.selectedItems).toEqual(callbackModelValue.item); 106 | expect(callbackModelValue.selectedItemsArray).toEqual([callbackModelValue.item]); 107 | expect(callbackModelValue.componentId).toEqual('comp1'); 108 | }); 109 | }) 110 | 111 | }); 112 | }); 113 | 114 | it('must overwrite the item if another item is selected', function () { 115 | browser.get(htmlFileName); 116 | 117 | expect($('input.ion-autocomplete-callback-model').evaluate('callbackValueModel')).toEqual(''); 118 | 119 | element(by.css('input.ion-autocomplete')).click().then(function () { 120 | expect($('input.ion-autocomplete-search').isDisplayed()).toBeTruthy(); 121 | 122 | element(by.css('input.ion-autocomplete-search')).sendKeys("test"); 123 | 124 | var itemList = element.all(by.css('[ng-repeat="item in viewModel.searchItems track by $index"]')); 125 | expectCollectionRepeatCount(itemList, 3); 126 | itemList.get(0).click().then(function () { 127 | expect($('input.ion-autocomplete-search').isDisplayed()).toBeFalsy(); 128 | expect($('input.ion-autocomplete-test-model').isDisplayed()).toBeTruthy(); 129 | expect($('input.ion-autocomplete-test-model').getAttribute('value')).toEqual('test1'); 130 | 131 | element(by.css('input.ion-autocomplete')).click().then(function () { 132 | expect($('input.ion-autocomplete-search').isDisplayed()).toBeTruthy(); 133 | element(by.css('input.ion-autocomplete-search')).sendKeys("test"); 134 | var itemList = element.all(by.css('[ng-repeat="item in viewModel.searchItems track by $index"]')); 135 | itemList.get(2).click().then(function () { 136 | expect($('input.ion-autocomplete-search').isDisplayed()).toBeFalsy(); 137 | expect($('input.ion-autocomplete-test-model').isDisplayed()).toBeTruthy(); 138 | expect($('input.ion-autocomplete-test-model').getAttribute('value')).toEqual('test3'); 139 | }) 140 | }); 141 | }) 142 | 143 | }); 144 | }); 145 | 146 | it('must call the done button clicked method if the done button is clicked', function () { 147 | browser.get(htmlFileName); 148 | 149 | expect($('input.ion-autocomplete-callback-model').evaluate('callbackValueModel')).toEqual(''); 150 | 151 | element(by.css('input.ion-autocomplete')).click().then(function () { 152 | expect($('input.ion-autocomplete-search').isDisplayed()).toBeTruthy(); 153 | 154 | element(by.css('input.ion-autocomplete-search')).sendKeys("test"); 155 | 156 | var itemList = element.all(by.css('[ng-repeat="item in viewModel.searchItems track by $index"]')); 157 | expectCollectionRepeatCount(itemList, 3); 158 | itemList.get(0).click().then(function () { 159 | expect($('input.ion-autocomplete-search').isDisplayed()).toBeFalsy(); 160 | expect($('input.ion-autocomplete-test-model').isDisplayed()).toBeTruthy(); 161 | expect($('input.ion-autocomplete-test-model').getAttribute('value')).toEqual('test1'); 162 | 163 | 164 | element(by.css('input.ion-autocomplete')).click().then(function () { 165 | // click the done button 166 | element(by.css('button.ion-autocomplete-cancel')).click().then(function () { 167 | expect($('input.ion-autocomplete-search').isDisplayed()).toBeFalsy(); 168 | 169 | // expect the callback value 170 | element(by.css('input.ion-autocomplete-done-callback-model')).evaluate('doneButtonCallbackValueModel').then(function (callbackModelValue) { 171 | expect(callbackModelValue.selectedItems.name).toEqual("test1"); 172 | expect(callbackModelValue.selectedItemsArray).toEqual([callbackModelValue.selectedItems]); 173 | expect(callbackModelValue.componentId).toEqual('comp1'); 174 | }); 175 | }); 176 | 177 | }); 178 | }) 179 | 180 | }); 181 | }); 182 | 183 | function expectCollectionRepeatCount(items, count) { 184 | for (var i = 0; i < count; i++) { 185 | expect(items.get(i).getText().isDisplayed()).toBeTruthy(); 186 | } 187 | //expect(items.get(count).getText().isDisplayed()).toBeFalsy(); 188 | } 189 | 190 | }); -------------------------------------------------------------------------------- /test/ion-autocomplete.multiple-select.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | describe('ion-autocomplete multiple select', function () { 4 | 5 | var scope, document, compile, q; 6 | 7 | // load the directive's module 8 | beforeEach(module('ionic')); 9 | beforeEach(module('ion-autocomplete')); 10 | 11 | beforeEach(inject(function ($rootScope, $document, $compile, $q) { 12 | scope = $rootScope.$new(); 13 | document = $document; 14 | compile = $compile; 15 | q = $q; 16 | })); 17 | 18 | afterEach(function () { 19 | // remove the autocomplete container from the dom after each test to have an empty body on each test start 20 | getSearchContainerElement().remove(); 21 | angular.element(document[0].querySelector('div.backdrop')).remove(); 22 | }); 23 | 24 | it('must set the selectItems label', function () { 25 | var selectItemLabelValue = "select-item"; 26 | compileElement(''); 27 | 28 | expect(getItemDividerElement(0)[0].innerText).toBe(selectItemLabelValue); 29 | }); 30 | 31 | it('must set the selectedItemsLabel label', function () { 32 | var selectedItemLabelValue = "selected-items"; 33 | compileElement(''); 34 | 35 | expect(getItemDividerElement(0)[0].innerText).toBe(selectedItemLabelValue); 36 | }); 37 | 38 | it('must hide/show the selectItems label if the items size changes', function () { 39 | var element = compileElement(''); 40 | 41 | // expect that the search container has ion-autocomplete-close class set 42 | expect(getSearchContainerElement().hasClass('ion-autocomplete-close')).toBe(true); 43 | expect(getSearchContainerElement().hasClass('ion-autocomplete-open')).toBe(false); 44 | 45 | // click on the element 46 | element.triggerHandler('click'); 47 | scope.$digest(); 48 | 49 | // expect that the search container has ion-autocomplete-open class set 50 | expect(getSearchContainerElement().hasClass('ion-autocomplete-close')).toBe(false); 51 | expect(getSearchContainerElement().hasClass('ion-autocomplete-open')).toBe(true); 52 | 53 | 54 | // expect that the selectItems divider is hidden 55 | expect(getItemDividerElement(1)[0]).toBe(undefined); 56 | 57 | // add some items 58 | element.controller('ionAutocomplete').searchItems = ["value1", "value2"]; 59 | scope.$digest(); 60 | 61 | // expect that the selectItems divider is shown 62 | expect(getItemDividerElement(1).hasClass('ng-hide')).toBeFalsy(); 63 | }); 64 | 65 | it('must hide the search container when the cancel field is clicked', function () { 66 | var element = compileElement(''); 67 | 68 | // expect that the search container has ion-autocomplete-close class set 69 | expect(getSearchContainerElement().hasClass('ion-autocomplete-close')).toBe(true); 70 | expect(getSearchContainerElement().hasClass('ion-autocomplete-open')).toBe(false); 71 | 72 | // click on the element 73 | element.triggerHandler('click'); 74 | scope.$digest(); 75 | 76 | // expect that the search container has ion-autocomplete-open class set 77 | expect(getSearchContainerElement().hasClass('ion-autocomplete-close')).toBe(false); 78 | expect(getSearchContainerElement().hasClass('ion-autocomplete-open')).toBe(true); 79 | 80 | // click on the cancel button 81 | var cancelButtonElement = getCancelButtonElement(); 82 | cancelButtonElement.triggerHandler('click'); 83 | scope.$digest(); 84 | 85 | // expect that the search container has ion-autocomplete-close class set 86 | expect(getSearchContainerElement().hasClass('ion-autocomplete-close')).toBe(true); 87 | expect(getSearchContainerElement().hasClass('ion-autocomplete-open')).toBe(false); 88 | }); 89 | 90 | /** 91 | * Compiles the given element and executes a digest cycle on the scope. 92 | * 93 | * @param element the element to compile 94 | * @returns {*} the compiled element 95 | */ 96 | function compileElement(element) { 97 | var compiledElement = compile(element)(scope); 98 | scope.$digest(); 99 | return compiledElement; 100 | } 101 | 102 | /** 103 | * Gets the angular element for the autocomplete search container div 104 | * @returns {*} the search container element 105 | */ 106 | function getSearchContainerElement() { 107 | return angular.element(document[0].querySelector('div.ion-autocomplete-container')) 108 | } 109 | 110 | /** 111 | * Gets the angular element for the autocomplete cancel button 112 | * @returns {*} the cancel button 113 | */ 114 | function getCancelButtonElement() { 115 | return angular.element(document[0].querySelector('button')) 116 | } 117 | 118 | /** 119 | * Gets the angular element for the autocomplete cancel button 120 | * @returns {*} the cancel button 121 | */ 122 | function getItemDividerElement(index) { 123 | return angular.element(document[0].querySelectorAll('ion-item.item-divider.item')[index]) 124 | } 125 | 126 | }); 127 | -------------------------------------------------------------------------------- /test/ion-autocomplete.single-select.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | describe('ion-autocomplete single select', function () { 4 | 5 | var templateUrl = 'test/templates/test-template.html'; 6 | var templateDataUrl = 'test/templates/test-template-data.html'; 7 | var templateDynamicUrl = 'test/templates/test-template-dynamic.html'; 8 | 9 | var scope, document, compile, q, templateCache, timeout; 10 | 11 | // load the directive's module 12 | beforeEach(module('ionic', 'ion-autocomplete', templateUrl, templateDataUrl, templateDynamicUrl)); 13 | 14 | beforeEach(inject(function ($rootScope, $document, $compile, $q, $templateCache, $timeout) { 15 | scope = $rootScope.$new(); 16 | document = $document; 17 | compile = $compile; 18 | q = $q; 19 | templateCache = $templateCache; 20 | timeout = $timeout; 21 | })); 22 | 23 | afterEach(function () { 24 | // remove the autocomplete container from the dom after each test to have an empty body on each test start 25 | getSearchContainerElement().remove(); 26 | angular.element(document[0].querySelector('div.backdrop')).remove(); 27 | angular.element(document[0].querySelector('div.test-template-div')).remove(); 28 | }); 29 | 30 | it('must have the default values set on attribute directive', function () { 31 | var element = compileElement(''); 32 | 33 | timeout(function () { 34 | // expect the default values of the input field 35 | expect(element[0].type).toBe('text'); 36 | expect(element[0].readOnly).toBe(true); 37 | expect(element.hasClass('ion-autocomplete')).toBe(true); 38 | expect(element[0].placeholder).toBe(''); 39 | 40 | // expect the default values of the search input field 41 | var searchInputElement = getSearchInputElement(); 42 | expect(searchInputElement[0].type).toBe('search'); 43 | expect(searchInputElement.hasClass('ion-autocomplete-search')).toBe(true); 44 | expect(searchInputElement.attr('placeholder')).toBe('Click to enter a value...'); 45 | 46 | // expect the placeholder icon element to no be platform dependent 47 | var placeholderIcon = getPlaceholderIconElement(); 48 | expect(placeholderIcon.hasClass('ion-search')).toBe(true); 49 | 50 | // expect the default values of the cancel button 51 | var cancelButtonElement = getCancelButtonElement(); 52 | expect(cancelButtonElement.hasClass('button')).toBe(true); 53 | expect(cancelButtonElement.hasClass('button-clear')).toBe(true); 54 | expect(cancelButtonElement.text()).toBe('Done'); 55 | }); 56 | 57 | }); 58 | 59 | it('must show no value in the input field if the model is not defined', function () { 60 | var element = compileElement(''); 61 | 62 | // expect the value of the input field to be empty 63 | expect(element[0].value).toBe(''); 64 | }); 65 | 66 | it('must show the value in the input field if the model is already defined', function () { 67 | scope.externalModel = "123"; 68 | scope.modelToItemMethod = function (query) { 69 | return 'Model ' + [query]; 70 | }; 71 | spyOn(scope, 'modelToItemMethod').and.callThrough(); 72 | var element = compileElement(''); 73 | 74 | // expect the value of the input field to be already set 75 | expect(element[0].value).toBe('Model 123'); 76 | }); 77 | 78 | it('must show the itemViewValueKey of the value in the input field if the model is already defined', function () { 79 | scope.model = { key: { value: "value1" } }; 80 | scope.modelToItemMethod = function (query) { 81 | return query; 82 | }; 83 | spyOn(scope, 'modelToItemMethod').and.callThrough(); 84 | var element = compileElement(''); 85 | 86 | // expect the value of the input field to be the evaluated itemViewValueKey expression on the model 87 | expect(element[0].value).toBe('value1'); 88 | }); 89 | 90 | it('must show the dynamic itemViewValueKey of the value in the input field if the model is already defined', function () { 91 | scope.model = { key: { value: "value1" } }; 92 | scope.modelToItemMethod = function (query) { 93 | return query; 94 | }; 95 | scope.dynamicViewValueKey = "key.value"; 96 | spyOn(scope, 'modelToItemMethod').and.callThrough(); 97 | var element = compileElement(''); 98 | 99 | // expect the value of the input field to be the evaluated itemViewValueKey expression on the model 100 | expect(element[0].value).toBe('value1'); 101 | }); 102 | 103 | it('must not show any value if the model is cleared', function () { 104 | scope.model = { key: { value: "value1" } }; 105 | scope.modelToItemMethod = function (query) { 106 | return query; 107 | }; 108 | spyOn(scope, 'modelToItemMethod').and.callThrough(); 109 | var element = compileElement(''); 110 | 111 | // expect the value of the input field to be the evaluated itemViewValueKey expression on the model 112 | expect(element[0].value).toBe('value1'); 113 | 114 | // clear the model 115 | scope.model = undefined; 116 | scope.$digest(); 117 | 118 | // expect the value of the input field to be cleared 119 | expect(element[0].value).toBe(''); 120 | }); 121 | 122 | it('must set the placeholder on the input field and on the search input field', function () { 123 | var placeholderValue = "placeholder value"; 124 | var element = compileElement(''); 125 | 126 | expect(element[0].placeholder).toBe(placeholderValue); 127 | expect(getSearchInputElement()[0].placeholder).toBe(placeholderValue); 128 | }); 129 | 130 | it('must set the template-url with a dynamically binded value', function () { 131 | var template = templateCache.get(templateDynamicUrl); 132 | templateCache.removeAll(); 133 | templateCache.put(templateDynamicUrl, template); 134 | 135 | scope.dynamicTemplateUrl = templateDynamicUrl; 136 | var element = compileElement(''); 137 | 138 | // click on the element 139 | element.triggerHandler('click'); 140 | scope.$digest(); 141 | 142 | // check that the new test template is shown 143 | expect(angular.element(document[0].querySelector('div#test-dynamic-template-div')).css('display')).toBe('block'); 144 | }); 145 | 146 | it('must set the cancel label on the button', function () { 147 | var cancelLabelValue = "Cancel Button"; 148 | compileElement(''); 149 | 150 | expect(getCancelButtonElement()[0].innerText).toBe(cancelLabelValue); 151 | }); 152 | 153 | it('must get the proper item value', function () { 154 | var element = compileElement(''); 155 | 156 | var itemValue = element.controller('ionAutocomplete').getItemValue("no-object"); 157 | expect(itemValue).toBe("no-object"); 158 | 159 | itemValue = element.controller('ionAutocomplete').getItemValue({ key: "value" }, "key"); 160 | expect(itemValue).toBe("value"); 161 | 162 | itemValue = element.controller('ionAutocomplete').getItemValue({ key: "value" }); 163 | expect(itemValue).toEqual({ key: "value" }); 164 | }); 165 | 166 | it('must get the proper item value with expressions', function () { 167 | var element = compileElement(''); 168 | 169 | var itemValue = element.controller('ionAutocomplete').getItemValue({ key: { value: "value1" } }, "key.value"); 170 | expect(itemValue).toBe("value1"); 171 | }); 172 | 173 | it('must not call the items method if the passed query is undefined', function () { 174 | scope.itemsMethod = function (query) { 175 | return ['item']; 176 | }; 177 | spyOn(scope, 'itemsMethod').and.callThrough(); 178 | var element = compileElement(''); 179 | 180 | scope.$digest(); 181 | 182 | expect(scope.itemsMethod.calls.count()).toBe(0); 183 | expect(element.controller('ionAutocomplete').searchItems.length).toBe(0); 184 | }); 185 | 186 | it('must call the items method if the passed query is empty', function () { 187 | scope.itemsMethod = function (query) { 188 | return ['item']; 189 | }; 190 | spyOn(scope, 'itemsMethod').and.callThrough(); 191 | var element = compileElement(''); 192 | 193 | element.controller('ionAutocomplete').searchQuery = ""; 194 | scope.$digest(); 195 | 196 | expect(scope.itemsMethod.calls.count()).toBe(1); 197 | expect(element.controller('ionAutocomplete').searchItems.length).toBe(1); 198 | }); 199 | 200 | it('must call the items method if the passed query is valid', function () { 201 | scope.itemsMethod = function (query) { 202 | return [query, 'item2']; 203 | }; 204 | spyOn(scope, 'itemsMethod').and.callThrough(); 205 | var element = compileElement(''); 206 | 207 | element.controller('ionAutocomplete').searchQuery = "asd"; 208 | scope.$digest(); 209 | 210 | expect(scope.itemsMethod.calls.count()).toBe(1); 211 | expect(scope.itemsMethod).toHaveBeenCalledWith("asd"); 212 | expect(element.controller('ionAutocomplete').searchItems.length).toBe(2); 213 | expect(element.controller('ionAutocomplete').searchItems).toEqual(['asd', 'item2']); 214 | }); 215 | 216 | it('must call the items method if the passed query is valid and the componentId is set', function () { 217 | scope.itemsMethod = function (query, componentId) { 218 | return [query, componentId, 'item2']; 219 | }; 220 | spyOn(scope, 'itemsMethod').and.callThrough(); 221 | var element = compileElement(''); 222 | 223 | element.controller('ionAutocomplete').searchQuery = "asd"; 224 | scope.$digest(); 225 | 226 | expect(scope.itemsMethod.calls.count()).toBe(1); 227 | expect(scope.itemsMethod).toHaveBeenCalledWith("asd", "compId"); 228 | expect(element.controller('ionAutocomplete').searchItems.length).toBe(3); 229 | expect(element.controller('ionAutocomplete').searchItems).toEqual(['asd', 'compId', 'item2']); 230 | }); 231 | 232 | it('must call the items method promise if the passed query is valid', function () { 233 | var deferred = q.defer(); 234 | 235 | scope.itemsMethod = function (query) { 236 | return deferred.promise; 237 | }; 238 | spyOn(scope, 'itemsMethod').and.callThrough(); 239 | var element = compileElement(''); 240 | 241 | element.controller('ionAutocomplete').searchQuery = "asd"; 242 | scope.$digest(); 243 | 244 | expect(scope.itemsMethod.calls.count()).toBe(1); 245 | expect(scope.itemsMethod).toHaveBeenCalledWith("asd"); 246 | expect(element.controller('ionAutocomplete').searchItems.length).toBe(0); 247 | 248 | // resolve the promise 249 | deferred.resolve(['asd', 'item2']); 250 | scope.$digest(); 251 | 252 | expect(element.controller('ionAutocomplete').searchItems.length).toBe(2); 253 | expect(element.controller('ionAutocomplete').searchItems).toEqual(['asd', 'item2']); 254 | }); 255 | 256 | it('must forward the items method promise error', function () { 257 | var deferred = q.defer(); 258 | var errorFunction = jasmine.createSpy("errorFunction"); 259 | 260 | // set the error function 261 | deferred.promise.then(function () { 262 | }, errorFunction); 263 | 264 | scope.itemsMethod = function (query) { 265 | return deferred.promise; 266 | }; 267 | spyOn(scope, 'itemsMethod').and.callThrough(); 268 | var element = compileElement(''); 269 | 270 | element.controller('ionAutocomplete').searchQuery = "asd"; 271 | scope.$digest(); 272 | 273 | expect(scope.itemsMethod.calls.count()).toBe(1); 274 | expect(scope.itemsMethod).toHaveBeenCalledWith("asd"); 275 | expect(element.controller('ionAutocomplete').searchItems.length).toBe(0); 276 | 277 | // resolve the promise 278 | deferred.reject('error'); 279 | scope.$digest(); 280 | 281 | expect(errorFunction.calls.count()).toBe(1); 282 | }); 283 | 284 | it('must allow standard $http promises', function () { 285 | var deferred = q.defer(); 286 | 287 | scope.itemsMethod = function (query) { 288 | return deferred.promise; 289 | }; 290 | spyOn(scope, 'itemsMethod').and.callThrough(); 291 | var element = compileElement(''); 292 | 293 | // add a text to the search query and execute a digest call 294 | element.controller('ionAutocomplete').searchQuery = "asd"; 295 | scope.$digest(); 296 | 297 | // assert that the items method is called once and that the list is still empty as the promise is not resolved yet 298 | expect(scope.itemsMethod.calls.count()).toBe(1); 299 | expect(scope.itemsMethod).toHaveBeenCalledWith("asd"); 300 | expect(element.controller('ionAutocomplete').searchItems.length).toBe(0); 301 | 302 | // resolve the promise and expect that the list has two items 303 | deferred.resolve({ data: [{ name: "name", view: "view" }, { name: "name1", view: "view1" }] }); 304 | scope.$digest(); 305 | expect(element.controller('ionAutocomplete').searchItems.length).toBe(2); 306 | }); 307 | 308 | it('must show the search container when the input field is clicked', function () { 309 | var element = compileElement(''); 310 | 311 | // expect that the search container has ion-autocomplete-close class set 312 | expect(getSearchContainerElement().hasClass('ion-autocomplete-close')).toBe(true); 313 | expect(getSearchContainerElement().hasClass('ion-autocomplete-open')).toBe(false); 314 | 315 | // click on the element 316 | element.triggerHandler('click'); 317 | scope.$digest(); 318 | 319 | // expect that the search container has ion-autocomplete-open class set 320 | expect(getSearchContainerElement().hasClass('ion-autocomplete-close')).toBe(false); 321 | expect(getSearchContainerElement().hasClass('ion-autocomplete-open')).toBe(true); 322 | }); 323 | 324 | it('must hide the search container when the cancel field is clicked', function () { 325 | var element = compileElement(''); 326 | 327 | // expect that the search container has ion-autocomplete-close class set 328 | expect(getSearchContainerElement().hasClass('ion-autocomplete-close')).toBe(true); 329 | expect(getSearchContainerElement().hasClass('ion-autocomplete-open')).toBe(false); 330 | 331 | // click on the element 332 | element.triggerHandler('click'); 333 | scope.$digest(); 334 | 335 | // expect that the search container has ion-autocomplete-open class set 336 | expect(getSearchContainerElement().hasClass('ion-autocomplete-close')).toBe(false); 337 | expect(getSearchContainerElement().hasClass('ion-autocomplete-open')).toBe(true); 338 | 339 | // click on the cancel button 340 | var cancelButtonElement = getCancelButtonElement(); 341 | cancelButtonElement.triggerHandler('click'); 342 | scope.$digest(); 343 | 344 | // expect that the search container has ion-autocomplete-open class set 345 | expect(getSearchContainerElement().hasClass('ion-autocomplete-close')).toBe(true); 346 | expect(getSearchContainerElement().hasClass('ion-autocomplete-open')).toBe(false); 347 | }); 348 | 349 | it('must be able to set a templateUrl', function () { 350 | var template = templateCache.get(templateUrl); 351 | templateCache.removeAll(); 352 | templateCache.put(templateUrl, template); 353 | 354 | var placeholder = "placeholder text"; 355 | var element = compileElement(''); 356 | 357 | // click on the element 358 | element.triggerHandler('click'); 359 | scope.$digest(); 360 | 361 | // check that the new test template is shown 362 | expect(angular.element(document[0].querySelector('div#test-template-div')).css('display')).toBe('block'); 363 | expect(angular.element(document[0].querySelector('div#test-template-div'))[0].innerText).toBe(placeholder); 364 | }); 365 | 366 | it('must be able to set a templateData', function () { 367 | var template = templateCache.get(templateDataUrl); 368 | templateCache.removeAll(); 369 | templateCache.put(templateDataUrl, template); 370 | 371 | scope.templateData = { 372 | testData: "test-data" 373 | }; 374 | var element = compileElement(''); 375 | 376 | // click on the element 377 | element.triggerHandler('click'); 378 | scope.$digest(); 379 | 380 | // check that the new test template is shown 381 | expect(angular.element(document[0].querySelector('div#test-template-data')).css('display')).toBe('block'); 382 | expect(angular.element(document[0].querySelector('div#test-template-data'))[0].innerText).toBe(scope.templateData.testData); 383 | }); 384 | 385 | it('must be able to open the search container externally', function () { 386 | var element = compileElement(''); 387 | 388 | // click on the element 389 | element.triggerHandler('click'); 390 | scope.$digest(); 391 | 392 | // expect that the search container has none set as display css attribute 393 | expect(getSearchContainerElement().hasClass('ion-autocomplete-close')).toBe(true); 394 | expect(getSearchContainerElement().hasClass('ion-autocomplete-open')).toBe(false); 395 | 396 | // show the search container externally 397 | element.controller('ionAutocomplete').showModal(); 398 | 399 | // expect that the search container has ion-autocomplete-open class set 400 | expect(getSearchContainerElement().hasClass('ion-autocomplete-close')).toBe(false); 401 | expect(getSearchContainerElement().hasClass('ion-autocomplete-open')).toBe(true); 402 | 403 | // show the search container externally 404 | element.controller('ionAutocomplete').hideModal(); 405 | 406 | // expect that the search container has ion-autocomplete-close class set 407 | expect(getSearchContainerElement().hasClass('ion-autocomplete-close')).toBe(true); 408 | expect(getSearchContainerElement().hasClass('ion-autocomplete-open')).toBe(false); 409 | }); 410 | 411 | it('must pass the outter ng-model-options to the inner search input field', function () { 412 | var element = compileElement(''); 413 | 414 | // click on the element 415 | element.triggerHandler('click'); 416 | scope.$digest(); 417 | 418 | // show the search container externally 419 | expect(getSearchInputElement().controller('ngModel').$options.debounce).toBe(1000); 420 | }); 421 | 422 | it('must remove the search container if the scope is destroyed', function () { 423 | var element = compileElement(''); 424 | 425 | // click on the element 426 | element.triggerHandler('click'); 427 | scope.$digest(); 428 | 429 | // check that the search container element is in the dom 430 | expect(getSearchContainerElement().length).toBe(1); 431 | 432 | // destroy the scope 433 | scope.$destroy(); 434 | 435 | // check that the search container element is not anymore in the dom 436 | expect(getSearchContainerElement().length).toBe(0); 437 | }); 438 | 439 | it('must be able to override ion-autocomplete-close and ion-autocomplete-open class', function () { 440 | var element = compileElement(''); 441 | 442 | // expect that the search container has close-class class set 443 | expect(getSearchContainerElement().hasClass('test-close-class')).toBe(true); 444 | expect(getSearchContainerElement().hasClass('test-open-class')).toBe(false); 445 | expect(getSearchContainerElement().hasClass('ion-autocomplete-close')).toBe(false); 446 | expect(getSearchContainerElement().hasClass('ion-autocomplete-open')).toBe(false); 447 | 448 | // click on the element 449 | element.triggerHandler('click'); 450 | scope.$digest(); 451 | 452 | // expect that the search container has open-class class set 453 | expect(getSearchContainerElement().hasClass('test-close-class')).toBe(false); 454 | expect(getSearchContainerElement().hasClass('test-open-class')).toBe(true); 455 | expect(getSearchContainerElement().hasClass('ion-autocomplete-close')).toBe(false); 456 | expect(getSearchContainerElement().hasClass('ion-autocomplete-open')).toBe(false); 457 | }); 458 | 459 | /** 460 | * Compiles the given element and executes a digest cycle on the scope. 461 | * 462 | * @param element the element to compile 463 | * @returns {*} the compiled element 464 | */ 465 | function compileElement(element) { 466 | var compiledElement = compile(element)(scope); 467 | scope.$digest(); 468 | return compiledElement; 469 | } 470 | 471 | /** 472 | * Gets the angular element for the autocomplete search container div 473 | * @returns {*} the search container element 474 | */ 475 | function getSearchContainerElement() { 476 | return angular.element(document[0].querySelector('div.ion-autocomplete-container')) 477 | } 478 | 479 | /** 480 | * Gets the angular element for the autocomplete placer holder icon 481 | * @returns {*} the search placeholder icon element 482 | */ 483 | function getPlaceholderIconElement() { 484 | return angular.element(document[0].querySelector('i.placeholder-icon')) 485 | } 486 | 487 | /** 488 | * Gets the angular element for the autocomplete search input field 489 | * @returns {*} the search input element 490 | */ 491 | function getSearchInputElement() { 492 | return angular.element(document[0].querySelector('input.ion-autocomplete-search')) 493 | } 494 | 495 | /** 496 | * Gets the angular element for the autocomplete cancel button 497 | * @returns {*} the cancel button 498 | */ 499 | function getCancelButtonElement() { 500 | return angular.element(document[0].querySelector('button')) 501 | } 502 | 503 | }); 504 | -------------------------------------------------------------------------------- /test/templates/test-template-data.html: -------------------------------------------------------------------------------- 1 |
{{viewModel.templateData.testData}}
-------------------------------------------------------------------------------- /test/templates/test-template-dynamic.html: -------------------------------------------------------------------------------- 1 |
{{viewModel.placeholder}}
-------------------------------------------------------------------------------- /test/templates/test-template.html: -------------------------------------------------------------------------------- 1 |
{{viewModel.placeholder}}
--------------------------------------------------------------------------------