diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e733e753..488cb2b41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ ### 0.2.11 +- [#283](https://github.com/miguelcobain/ember-paper/pull/283) Adds support for `fullTextSearch` attribute on `{{paper-autocomplete}}`. Enables passing Promises to the `model` attribute on `{{paper-autocomplete}}`. Docs updated. - [#253](https://github.com/miguelcobain/ember-paper/pull/253) Add `closeOnClick` to paper-sidenav - [#255](https://github.com/miguelcobain/ember-paper/pull/255) Add class to backdrops - [#260](https://github.com/miguelcobain/ember-paper/pull/260) Set jquery version to 1.11.3 diff --git a/addon/components/paper-autocomplete.js b/addon/components/paper-autocomplete.js index c878450f5..a2ab43257 100644 --- a/addon/components/paper-autocomplete.js +++ b/addon/components/paper-autocomplete.js @@ -2,6 +2,29 @@ import Ember from 'ember'; import HasBlockMixin from '../mixins/hasblock-mixin'; import { promiseArray } from 'ember-paper/utils/promise-proxies'; +const { + get, + set, + setProperties, + isEmpty, + isEqual, + observer, + computed, + ObjectProxy, + run, + isArray, + assert, + isPresent, + getProperties +} = Ember; + +const { + not, + alias, + and, + bool +} = computed; + function isString(item) { return typeof item === 'string' || item instanceof String; } @@ -38,11 +61,14 @@ export default Ember.Component.extend(HasBlockMixin, { searchText: '', // wrap in a computed property so that cache // isn't shared among autocomplete instances - itemCache: Ember.computed(function() { - return {}; + itemCache: computed({ + get(){ + return {}; + } }), // Public + fullTextSearch: false, disabled: null, required: null, lookupKey: null, @@ -55,18 +81,33 @@ export default Ember.Component.extend(HasBlockMixin, { init() { this._super(...arguments); - - if (this.get('model')) { - this.set('searchText', this.lookupLabelOfItem(this.get('model'))); - this.searchTextDidChange(); + let model = get(this,'model'); + if (model) { + // handle the case where model is a promise + if (model instanceof ObjectProxy){ + model.then((instance)=>{ + if (!isEmpty(instance)){ + set(this,'searchText', this.lookupLabelOfItem(instance)); + set(this,'model',instance); + } + // as this code would run async after the actual component + // init is made we need a way to monitor the fact of + // init finish in modelDidChange. + set(this,'initFinished', true); + }); + } else { + set(this,'searchText', this.lookupLabelOfItem(get(this,'model'))); + this.searchTextDidChange(); + set(this,'initFinished',true); + } } }, - notFloating: Ember.computed.not('floating'), - notHidden: Ember.computed.not('hidden'), + notFloating: not('floating'), + notHidden: not('hidden'), - autocompleteWrapperId: Ember.computed('elementId', function() { - return 'autocomplete-wrapper-' + this.get('elementId'); + autocompleteWrapperId: computed('elementId', function() { + return `autocomplete-wrapper-${get(this,'elementId')}`; }), sections: { @@ -74,60 +115,73 @@ export default Ember.Component.extend(HasBlockMixin, { notFoundTemplate: {isNotFoundTemplate: true} }, - notFoundMsg: Ember.computed('searchText', 'notFoundMessage', function() { - return Ember.String.fmt(this.get('notFoundMessage'), [this.get('searchText')]); + notFoundMsg: computed('searchText', 'notFoundMessage', function() { + return Ember.String.loc(this.get('notFoundMessage'), [this.get('searchText')]); }), /** * Needed because of false = disabled='false'. */ - showDisabled: Ember.computed('disabled', function() { - if (this.get('disabled')) { - return true; - } - }), - - showLoadingBar: Ember.computed('loading', 'allowNonExisting', 'debouncingState', function() { - return !this.get('loading') && !this.get('allowNonExisting') && !this.get('debouncingState'); - }), - - enableClearButton: Ember.computed('searchText', 'disabled', function() { - return this.get('searchText') && !this.get('disabled'); - }), + showDisabled: alias('disabled'), + notLoading: not('loading'), + notAllowNonExisting: not('allowNonExisting'), + notDebouncingState: not('debouncingState'), + enabled: not('disabled'), + showLoadingBar: and('notLoading', 'notAllowNonExisting', 'notDebouncingState'), + enableClearButton: and('searchText','enabled'), /** * Source filtering logic */ - searchTextDidChange: Ember.observer('searchText', function() { - var searchText = this.get('searchText'); - if (searchText !== this.get('previousSearchText')) { - if (!this.get('allowNonExisting')) { - this.set('model', null); + searchTextDidChange: observer('searchText', function() { + let searchText = get(this,'searchText'); + if (!isEqual(searchText, get(this,'previousSearchText'))) { + if (get(this,'notAllowNonExisting')) { + set(this,'model', null); } else { - this.set('model', searchText); + set(this,'model', searchText); } this.sendAction('update-filter', searchText); - this.set('debouncingState', true); - Ember.run.debounce(this, this.setDebouncedSearchText, this.get('delay')); - this.set('previousSearchText', searchText); + set(this,'debouncingState', true); + run.debounce(this, 'setDebouncedSearchText', get(this,'delay')); + set(this,'previousSearchText', searchText); } }), - modelDidChange: Ember.observer('model', function() { - var model = this.get('model'); - var value = this.lookupLabelOfItem(model); + finishModelChange(data){ + let value = this.lookupLabelOfItem(data); // First set previousSearchText then searchText ( do not trigger observer only update value! ). - this.set('previousSearchText', value); - this.set('searchText', value); - this.set('hidden', true); + set(this,'previousSearchText', value); + set(this,'searchText', value); + set(this,'hidden', true); + }, + + modelDidChange: observer('model', function() { + // we don't want this hook to run before the async init + // of the component finishes. null/undefined model value also + // breaks execution + if (get(this,'initFinished') && !isEmpty(get(this,'model'))){ + let model = get(this,'model'); + // sometimes model is a promise. + if (model.then) { + model.then((data)=>{ + // the promise content might be null as well + if (!isEmpty(data)){ + this.finishModelChange(data); + } + }); + } else { + this.finishModelChange(model); + } + } }), setDebouncedSearchText() { - var searchText = this.get('searchText'); - if (this.get('isMinLengthMet')) { + let searchText = get(this,'searchText'); + if (get(this,'isMinLengthMet')) { this.sendAction('debounced-update-filter', searchText); if (!this.cacheGet(searchText)) { this.sendAction('cache-miss', searchText); @@ -146,25 +200,25 @@ export default Ember.Component.extend(HasBlockMixin, { this.set('debouncingState', false); }, - loading: Ember.computed.bool('sourcePromiseArray.isPending').readOnly(), + loading: bool('sourcePromiseArray.isPending').readOnly(), //coalesces all promises into PromiseArrays or Arrays - sourcePromiseArray: Ember.computed('source', function() { - var source = this.get('source'); + sourcePromiseArray: computed('source', function() { + let source = get(this,'source'); if (source && source.then) { //coalesce into promise array return promiseArray(source); - } else if (Ember.isArray(source)) { + } else if (isArray(source)) { //return array return Ember.A(source); } else { //Unknown source type - Ember.assert('The provided \'source\' for paper-autocomplete must be an Array or a Promise.', !Ember.isPresent(source)); + assert('The provided \'source\' for paper-autocomplete must be an Array or a Promise.', !isPresent(source)); return Ember.A(); } }).readOnly(), - suggestions: Ember.computed('debouncedSearchText', 'sourcePromiseArray.[]', function() { + suggestions: computed('debouncedSearchText', 'sourcePromiseArray.[]', function() { var source = this.get('sourcePromiseArray'); var lookupKey = this.get('lookupKey'); var searchText = (this.get('debouncedSearchText') || '').toLowerCase(); @@ -186,110 +240,135 @@ export default Ember.Component.extend(HasBlockMixin, { } // If we have no item suggestions, and allowNonExisting is enabled // We need to close the paper-autocomplete-list so all mouse events get activated again. - if (suggestions.length === 0 && this.get('allowNonExisting')){ - this.set('hidden', true); + if (isEqual(suggestions.length,0) && get(this,'allowNonExisting')){ + set(this,'hidden', true); } return suggestions; }).readOnly(), filterArray(array, searchText, lookupKey) { - return array.filter(function(item) { - Ember.assert('You have not defined \'lookupKey\' on paper-autocomplete, when source contained ' + - 'items that are not of type String. To fix this error provide a ' + - 'lookupKey=\'key to lookup from source item\'.', isString(item) || Ember.isPresent(lookupKey)); - - Ember.assert('You specified \'' + lookupKey + '\' as a lookupKey on paper-autocomplete, ' + - 'but at least one of its values is not of type String. To fix this error make sure that every \'' + lookupKey + - '\' value is a string.', isString(item) || (Ember.isPresent(lookupKey) && isString(Ember.get(item, lookupKey))) ); - - var search = isString(item) ? item.toLowerCase() : Ember.get(item, lookupKey).toLowerCase(); - return search.indexOf(searchText) === 0; + return array.filter((item)=>{ + assert(`You have not defined 'lookupKey' on paper-autocomplete, when source contained \ + items that are not of type String. To fix this error provide a \ + lookupKey='key to lookup from source item'.`, isString(item) || isPresent(lookupKey)); + + assert(`You specified '${lookupKey}' as a lookupKey on paper-autocomplete, \ + but at least one of its values is not of type String. To fix this error make sure that every \ + '${lookupKey}' value is a string.`, + isString(item) || (isPresent(lookupKey) && isString(get(item, lookupKey))) ); + + let search = isString(item) ? item.toLowerCase() : get(item, lookupKey).toLowerCase(); + if (get(this,'fullTextSearch')){ + return search.indexOf(searchText) !== -1; + } else { + return search.indexOf(searchText) === 0; + } + }); }, //TODO move cache to service? Components are not singletons. cacheGet(text) { - return !this.get('noCache') && this.get('itemCache')[text]; + return !get(this,'noCache') && get(this,'itemCache')[text]; }, cacheSet(text, data) { - this.get('itemCache')[text] = data; + get(this,'itemCache')[text] = data; }, - shouldHide: Ember.computed.not('isMinLengthMet'), + shouldHide: not('isMinLengthMet'), - isMinLengthMet: Ember.computed('searchText', 'minLength', function() { - return this.get('searchText').length >= this.get('minLength'); + isMinLengthMet: computed('searchText.length',{ + get(){ + return get(this,'searchText.length') >= get(this,'minLength'); + } }), /** * Returns the default index based on whether or not autoselect is enabled. * @returns {number} */ - defaultIndex: Ember.computed('autoselect', function() { - return this.get('autoselect') ? 0 : -1; + defaultIndex: computed('autoselect', function() { + return get(this,'autoselect') ? 0 : -1; }), lookupLabelOfItem(model) { - return this.get('lookupKey') ? Ember.get(model, this.get('lookupKey')) : model; + return get(this,'lookupKey') ? get(model, get(this,'lookupKey')) : model; }, actions: { clear() { - this.set('searchText', ''); - this.set('selectedIndex', -1); - this.set('model', null); - this.set('hidden', this.get('shouldHide')); + setProperties(this,{ + searchText:'', + selectedIndex:-1, + model:null, + hidden:get(this,'shouldHide') + }); }, pickModel(model) { - this.set('model', model); - var value = this.lookupLabelOfItem(model); - // First set previousSearchText then searchText ( do not trigger observer only update value! ). - this.set('previousSearchText', value); - this.set('searchText', value); - this.set('hidden', true); + let value = this.lookupLabelOfItem(model); + setProperties(this,{ + model:model, + previousSearchText:value, + searchText:value, + hidden:true + }); }, inputFocusOut() { - this.set('hasFocus', false); - if (this.get('noBlur') === false) { - this.set('hidden', true); + set(this,'hasFocus', false); + if (get(this,'noBlur') === false) { + set(this,'hidden', true); } }, inputFocusIn() { - this.set('hasFocus', true); - this.set('hidden', this.get('shouldHide')); + setProperties(this,{ + hasFocus: true, + hidden: get(this,'shouldHide') + }); }, inputKeyDown(value, event) { + let { + constants, + selectedIndex, + suggestions + } = getProperties(this, + [ + 'constants', + 'selectedIndex', + 'suggestions' + ]); switch (event.keyCode) { - case this.get('constants').KEYCODE.DOWN_ARROW: - if (this.get('loading')) { + case constants.KEYCODE.DOWN_ARROW: + if (get(this,'loading')) { return; } - this.set('selectedIndex', Math.min(this.get('selectedIndex') + 1, this.get('suggestions').length - 1)); + set(this,'selectedIndex', Math.min(selectedIndex + 1, suggestions.length - 1)); break; - case this.get('constants').KEYCODE.UP_ARROW: - if (this.get('loading')) { + case constants.KEYCODE.UP_ARROW: + if (get(this,'loading')) { return; } - this.set('selectedIndex', this.get('selectedIndex') < 0 ? this.get('suggestions').length - 1 : Math.max(0, this.get('selectedIndex') - 1)); + set(this,'selectedIndex', selectedIndex < 0 ? suggestions.length - 1 : Math.max(0, selectedIndex - 1)); break; - case this.get('constants').KEYCODE.TAB: - case this.get('constants').KEYCODE.ENTER: - if (this.get('hidden') || this.get('loading') || this.get('selectedIndex') < 0 || this.get('suggestions').length < 1) { + case constants.KEYCODE.TAB: + case constants.KEYCODE.ENTER: + if (get(this,'hidden') || get(this,'loading') || selectedIndex < 0 || suggestions.length < 1) { return; } - this.send('pickModel', this.get('suggestions').objectAt(this.get('selectedIndex'))); + this.send('pickModel', suggestions.objectAt(selectedIndex)); break; - case this.get('constants').KEYCODE.ESCAPE: - this.set('searchText', ''); - this.set('selectedIndex', this.get('defaultIndex')); - this.set('model', null); - this.set('hidden', this.get('shouldHide')); + case constants.KEYCODE.ESCAPE: + setProperties(this,{ + searchText:'', + selectedIndex:get(this,'defaultIndex'), + model:null, + hidden:get(this,'shouldHide') + }); break; default: break; @@ -297,13 +376,13 @@ export default Ember.Component.extend(HasBlockMixin, { }, listMouseEnter() { - this.set('noBlur', true); + set(this,'noBlur', true); }, listMouseLeave() { - this.set('noBlur', false); - if (this.get('hasFocus') === false) { - this.set('hidden', true); + set(this,'noBlur', false); + if (isEqual(get(this,'hasFocus'),false)) { + set(this,'hidden', true); } }, diff --git a/bower.json b/bower.json index 8a786e09c..939a2e0fc 100644 --- a/bower.json +++ b/bower.json @@ -15,4 +15,4 @@ "prism": "*", "qunit": "~1.20.0" } -} +} \ No newline at end of file diff --git a/tests/dummy/app/controllers/autocomplete.js b/tests/dummy/app/controllers/autocomplete.js index 19e3afa7f..adfe3cbe1 100644 --- a/tests/dummy/app/controllers/autocomplete.js +++ b/tests/dummy/app/controllers/autocomplete.js @@ -45,7 +45,17 @@ export default Ember.Controller.extend({ }, arrayOfItems: ['Ember', 'Paper', 'One', 'Two', 'Three','Four', 'Five', 'Six', 'Seven', 'Eight', 'Nine', 'Ten', 'Eleven', 'Twelve'], - + dataFromFullTextPromise: [ + { + name:'full-text search' + }, + { + name:'not full-text search' + }, + { + name:'search' + } + ], /** * Array of static Objects. * When having objects, use lookupKey="name" on the paper-autocomplete component so it knows to use "name" to search in. diff --git a/tests/dummy/app/templates/autocomplete.hbs b/tests/dummy/app/templates/autocomplete.hbs index 0615255cd..611369181 100644 --- a/tests/dummy/app/templates/autocomplete.hbs +++ b/tests/dummy/app/templates/autocomplete.hbs @@ -101,12 +101,58 @@ } } ...{{/code-block}} +

+ Sometimes you want to prepopulate the value + for the autocomplete field on load. You can set the model such that it points to the value that should be displayed on load. This value might also be a promise. The autocomplete would wait for the value to load and populate the input according to the lookupKey passed to the component in template. +

+ + + +{{/paper-card-content}} +{{/paper-card}} + +{{#paper-card}} +{{#paper-card-content}} +

Full-text search

+
You can use the fullTextSearch=true attribute if you want to enable full-text search on the component.
+

I.e. if the promise provided to the source argument can only return 3 values:

+ {{#code-block language="javascript"}} + { + name:'full-text search' + }, + { + name:'not full-text search' + }, + { + name:'search' + } + {{/code-block}} +

+ and you would type the `search` into the autocomplete - with full-text search enabled you would get all 3 results to choose from. Without the full-text search enabled you would get only the last one as it is the only one that starts with the text you have typed. +

+ +

Full-text search enabled

+ + {{paper-autocomplete minLength=5 delay=300 placeholder="Type e.g. search" source=dataFromFullTextPromise lookupKey="name" fullTextSearch=true}} + + +

Template

+ {{#code-block language="handlebars"}} + \{{paper-autocomplete minLength=5 delay=300 placeholder="Type e.g. search" source=dataFromFullTextPromise lookupKey="name" fullTextSearch=true}}{{/code-block}} +

Full-text search disabled

+ {{paper-autocomplete minLength=5 delay=300 placeholder="Type e.g. search" source=dataFromFullTextPromise lookupKey="name"}} + + +

Template

+ {{#code-block language="handlebars"}} + \{{paper-autocomplete minLength=5 delay=300 placeholder="Type e.g. search" source=dataFromFullTextPromise lookupKey="name"}}{{/code-block}} {{/paper-card-content}} {{/paper-card}} + {{#paper-card}} {{#paper-card-content}}

Block Custom template

@@ -286,6 +332,11 @@ Whoops! Could not find "\{{searchText}}".{{/code-block}} string The message to display if no items was found. Default is: No matches found for "%@".. The %@ part will be replaced by the users input. + + fullTextSearch + boolean + Makes the autocomplete to become full-text search aware so the search would not only return the values that start with the input of the autocomplete but with all values that have occurences of this text. Default is: false. + diff --git a/tests/unit/components/paper-autocomplete-test.js b/tests/unit/components/paper-autocomplete-test.js index 1875f692e..1d5218da8 100644 --- a/tests/unit/components/paper-autocomplete-test.js +++ b/tests/unit/components/paper-autocomplete-test.js @@ -1,4 +1,5 @@ import { moduleForComponent, test } from 'ember-qunit'; +import Ember from 'ember'; moduleForComponent('paper-autocomplete', 'Unit | Component | paper autocomplete', { // Specify the other units that are required for this test @@ -41,4 +42,57 @@ test('it propagates placeholder to input box', function(assert) { assert.equal(component.$().find('input').attr('placeholder'), 'Testing', 'Sets correct placeholder on input box.'); }); +test('full-text search', function(assert) { + assert.expect(1); + + // Creates the component instance + let component = this.subject(); + // Renders the component to the page + this.render(); + Ember.run(()=>{ + component.set('source', [ + { + name:'full-text search' + }, + { + name:'not full-text search' + }, + { + name:'search' + } + ]); + component.set('fullTextSearch',true); + component.set('lookupKey','name'); + component.set('searchText','search'); + component.set('debouncedSearchText','search'); + }); + assert.equal(component.get('suggestions').length,3); +}); + +test('non full-text search', function(assert) { + assert.expect(1); + + // Creates the component instance + let component = this.subject(); + // Renders the component to the page + this.render(); + Ember.run(()=>{ + component.set('source', [ + { + name:'full-text search' + }, + { + name:'not full-text search' + }, + { + name:'search' + } + ]); + component.set('fullTextSearch',false); + component.set('lookupKey','name'); + component.set('searchText','search'); + component.set('debouncedSearchText','search'); + }); + assert.equal(component.get('suggestions').length,1); +});