diff --git a/apps/advanced/frontend/widgets/Alert.php b/apps/advanced/frontend/widgets/Alert.php index 4af0031d25..93ea78d563 100644 --- a/apps/advanced/frontend/widgets/Alert.php +++ b/apps/advanced/frontend/widgets/Alert.php @@ -16,7 +16,7 @@ namespace frontend\widgets; * - \Yii::$app->getSession()->setFlash('info', 'This is the message'); * * @author Kartik Visweswaran - * @author Alexander Makarov + * @author Alexander Makarov */ class Alert extends \yii\bootstrap\Widget { diff --git a/apps/basic/tests/acceptance.suite.yml b/apps/basic/tests/acceptance.suite.yml index 34e43977ab..b3852841d4 100644 --- a/apps/basic/tests/acceptance.suite.yml +++ b/apps/basic/tests/acceptance.suite.yml @@ -15,6 +15,8 @@ modules: - PhpBrowser # you can use WebDriver instead of PhpBrowser to test javascript and ajax. # This will require you to install selenium. See http://codeception.com/docs/04-AcceptanceTests#Selenium +# "restart" option is used by the WebDriver to start each time per test-file new session and cookies, +# it is useful if you want to login in your app in each test. # - WebDriver config: PhpBrowser: @@ -22,3 +24,4 @@ modules: # WebDriver: # url: 'http://localhost' # browser: firefox +# restart: true diff --git a/docs/guide/apps-advanced.md b/docs/guide/apps-advanced.md index 0c55564ef4..13253465fe 100644 --- a/docs/guide/apps-advanced.md +++ b/docs/guide/apps-advanced.md @@ -33,7 +33,7 @@ the installed application. You only need to do these once for all. ``` php /path/to/yii-application/init ``` -2. Create a new database and adjust the `components.db` configuration in `common/config/params-local.php` accordingly. +2. Create a new database and adjust the `components.db` configuration in `common/config/main-local.php` accordingly. 3. Apply migrations with console command `yii migrate`. 4. Set document roots of your Web server: diff --git a/extensions/debug/Module.php b/extensions/debug/Module.php index 9e19ff599a..a63225db26 100644 --- a/extensions/debug/Module.php +++ b/extensions/debug/Module.php @@ -38,13 +38,11 @@ class Module extends \yii\base\Module */ public $logTarget; /** - * @var array|Panel[] + * @var array list of debug panels. The array keys are the panel IDs, and values are the corresponding + * panel class names or configuration arrays. This will be merged with [[corePanels()]]. + * You may set a panel to be false to disable a core panel. */ public $panels = []; - /** - * @var string postion of the custom configured panels 'begin' or 'end' - */ - public $panelsPosition = 'end'; /** * @var string the directory storing the debugger data files. This can be specified using a path alias. */ @@ -78,15 +76,7 @@ class Module extends \yii\base\Module Yii::$app->getView()->on(View::EVENT_END_BODY, [$this, 'renderToolbar']); }); - switch ($this->panelsPosition) { - case 'begin': - $this->panels = ArrayHelper::merge($this->panels, $this->corePanels()); - break; - case 'end': - default: - $this->panels = ArrayHelper::merge($this->corePanels(), $this->panels); - break; - } + $this->panels = array_filter(ArrayHelper::merge($this->corePanels(), $this->panels)); foreach ($this->panels as $id => $config) { $config['module'] = $this; $config['id'] = $id; diff --git a/extensions/elasticsearch/CHANGELOG.md b/extensions/elasticsearch/CHANGELOG.md index 483b05642b..f95acaeec8 100644 --- a/extensions/elasticsearch/CHANGELOG.md +++ b/extensions/elasticsearch/CHANGELOG.md @@ -5,6 +5,7 @@ Yii Framework 2 elasticsearch extension Change Log ---------------------------- - Bug #1993: afterFind event in AR is now called after relations have been populated (cebe, creocoder) +- Bug #2324: Fixed QueryBuilder bug when building a query with "query" option (mintao) - Enh #1313: made index and type available in `ActiveRecord::instantiate()` to allow creating records based on elasticsearch type when doing cross index/type search (cebe) - Enh #1382: Added a debug toolbar panel for elasticsearch (cebe) - Enh #1765: Added support for primary key path mapping, pk can now be part of the attributes when mapping is defined (cebe) diff --git a/extensions/elasticsearch/QueryBuilder.php b/extensions/elasticsearch/QueryBuilder.php index b6cce78e3b..e9511ea933 100644 --- a/extensions/elasticsearch/QueryBuilder.php +++ b/extensions/elasticsearch/QueryBuilder.php @@ -55,8 +55,10 @@ class QueryBuilder extends \yii\base\Object $parts['from'] = (int) $query->offset; } - if (empty($parts['query'])) { + if (empty($query->query)) { $parts['query'] = ["match_all" => (object)[]]; + } else { + $parts['query'] = $query->query; } $whereFilter = $this->buildCondition($query->where); diff --git a/extensions/gii/CHANGELOG.md b/extensions/gii/CHANGELOG.md index 61b513d3fc..dde6f17535 100644 --- a/extensions/gii/CHANGELOG.md +++ b/extensions/gii/CHANGELOG.md @@ -5,6 +5,7 @@ Yii Framework 2 gii extension Change Log ---------------------------- - Bug #1405: fixed disambiguation of relation names generated by gii (qiangxue) +- Bug #1904: Fixed autocomplete to work with underscore inputs "_" (tonydspaniard) - Bug #2298: Fixed the bug that Gii controller generator did not allow digit in the controller ID (qiangxue) - Bug: fixed controller in crud template to avoid returning query in findModel() (cebe) - Enh #1624: generate rules for unique indexes (lucianobaraglia) diff --git a/extensions/gii/assets/gii.js b/extensions/gii/assets/gii.js index a7f66572b9..c5a70abb3e 100644 --- a/extensions/gii/assets/gii.js +++ b/extensions/gii/assets/gii.js @@ -71,6 +71,15 @@ yii.gii = (function ($) { }; return { + autocomplete: function (counter, data) { + var datum = new Bloodhound({ + datumTokenizer: function(d){return Bloodhound.tokenizers.whitespace(d.word);}, + queryTokenizer: Bloodhound.tokenizers.whitespace, + local: data + }); + datum.initialize(); + jQuery('.typeahead-'+counter).typeahead(null,{displayKey: 'word', source: datum.ttAdapter()}); + }, init: function () { initHintBlocks(); initStickyInputs(); diff --git a/extensions/gii/assets/typeahead.js b/extensions/gii/assets/typeahead.js index 9365bd62a1..ffba71dd4f 100644 --- a/extensions/gii/assets/typeahead.js +++ b/extensions/gii/assets/typeahead.js @@ -1,15 +1,13 @@ /*! - * typeahead.js 0.9.3 - * https://github.com/twitter/typeahead + * typeahead.js 0.10.0 + * https://github.com/twitter/typeahead.js * Copyright 2013 Twitter, Inc. and other contributors; Licensed MIT */ (function($) { - var VERSION = "0.9.3"; - var utils = { + var _ = { isMsie: function() { - var match = /(msie) ([\w.]+)/i.exec(navigator.userAgent); - return match ? parseInt(match[2], 10) : false; + return /(msie|trident)/i.test(navigator.userAgent) ? navigator.userAgent.match(/(msie |rv:)(\d+(.\d+)?)/i)[2] : false; }, isBlankString: function(str) { return !str || /^\s*$/.test(str); @@ -30,21 +28,12 @@ return typeof obj === "undefined"; }, bind: $.proxy, - bindAll: function(obj) { - var val; - for (var key in obj) { - $.isFunction(val = obj[key]) && (obj[key] = $.proxy(val, obj)); + each: function(collection, cb) { + $.each(collection, reverseArgs); + function reverseArgs(index, value) { + return cb(value, index); } }, - indexOf: function(haystack, needle) { - for (var i = 0; i < haystack.length; i++) { - if (haystack[i] === needle) { - return i; - } - } - return -1; - }, - each: $.each, map: $.map, filter: $.grep, every: function(obj, test) { @@ -78,6 +67,12 @@ return counter++; }; }(), + templatify: function templatify(obj) { + return $.isFunction(obj) ? obj : template; + function template() { + return String(obj); + } + }, defer: function(fn) { setTimeout(fn, 0); }, @@ -123,69 +118,69 @@ return result; }; }, - tokenizeQuery: function(str) { - return $.trim(str).toLowerCase().split(/[\s]+/); - }, - tokenizeText: function(str) { - return $.trim(str).toLowerCase().split(/[\s\-_]+/); - }, - getProtocol: function() { - return location.protocol; - }, noop: function() {} }; - var EventTarget = function() { - var eventSplitter = /\s+/; - return { - on: function(events, callback) { - var event; - if (!callback) { - return this; - } - this._callbacks = this._callbacks || {}; - events = events.split(eventSplitter); - while (event = events.shift()) { - this._callbacks[event] = this._callbacks[event] || []; - this._callbacks[event].push(callback); - } - return this; - }, - trigger: function(events, data) { - var event, callbacks; - if (!this._callbacks) { - return this; - } - events = events.split(eventSplitter); - while (event = events.shift()) { - if (callbacks = this._callbacks[event]) { - for (var i = 0; i < callbacks.length; i += 1) { - callbacks[i].call(this, { - type: event, - data: data - }); - } - } - } - return this; - } - }; - }(); - var EventBus = function() { - var namespace = "typeahead:"; - function EventBus(o) { - if (!o || !o.el) { - $.error("EventBus initialized without el"); - } - this.$el = $(o.el); + var VERSION = "0.10.0"; + var LruCache = function(root, undefined) { + function LruCache(maxSize) { + this.maxSize = maxSize || 100; + this.size = 0; + this.hash = {}; + this.list = new List(); } - utils.mixin(EventBus.prototype, { - trigger: function(type) { - var args = [].slice.call(arguments, 1); - this.$el.trigger(namespace + type, args); + _.mixin(LruCache.prototype, { + set: function set(key, val) { + var tailItem = this.list.tail, node; + if (this.size >= this.maxSize) { + this.list.remove(tailItem); + delete this.hash[tailItem.key]; + } + if (node = this.hash[key]) { + node.val = val; + this.list.moveToFront(node); + } else { + node = new Node(key, val); + this.list.add(node); + this.hash[key] = node; + this.size++; + } + }, + get: function get(key) { + var node = this.hash[key]; + if (node) { + this.list.moveToFront(node); + return node.val; + } } }); - return EventBus; - }(); + function List() { + this.head = this.tail = null; + } + _.mixin(List.prototype, { + add: function add(node) { + if (this.head) { + node.next = this.head; + this.head.prev = node; + } + this.head = node; + this.tail = this.tail || node; + }, + remove: function remove(node) { + node.prev ? node.prev.next = node.next : this.head = node.next; + node.next ? node.next.prev = node.prev : this.tail = node.prev; + }, + moveToFront: function(node) { + this.remove(node); + this.add(node); + } + }); + function Node(key, val) { + this.key = key; + this.val = val; + this.prev = this.next = null; + } + return LruCache; + }(this); var PersistentStorage = function() { var ls, methods; try { @@ -215,7 +210,7 @@ return decode(ls.getItem(this._prefix(key))); }, set: function(key, val, ttl) { - if (utils.isNumber(ttl)) { + if (_.isNumber(ttl)) { ls.setItem(this._ttlKey(key), encode(now() + ttl)); } else { ls.removeItem(this._ttlKey(key)); @@ -241,416 +236,773 @@ }, isExpired: function(key) { var ttl = decode(ls.getItem(this._ttlKey(key))); - return utils.isNumber(ttl) && now() > ttl ? true : false; + return _.isNumber(ttl) && now() > ttl ? true : false; } }; } else { methods = { - get: utils.noop, - set: utils.noop, - remove: utils.noop, - clear: utils.noop, - isExpired: utils.noop + get: _.noop, + set: _.noop, + remove: _.noop, + clear: _.noop, + isExpired: _.noop }; } - utils.mixin(PersistentStorage.prototype, methods); + _.mixin(PersistentStorage.prototype, methods); return PersistentStorage; function now() { return new Date().getTime(); } function encode(val) { - return JSON.stringify(utils.isUndefined(val) ? null : val); + return JSON.stringify(_.isUndefined(val) ? null : val); } function decode(val) { return JSON.parse(val); } }(); - var RequestCache = function() { - function RequestCache(o) { - utils.bindAll(this); - o = o || {}; - this.sizeLimit = o.sizeLimit || 10; - this.cache = {}; - this.cachedKeysByAge = []; - } - utils.mixin(RequestCache.prototype, { - get: function(url) { - return this.cache[url]; - }, - set: function(url, resp) { - var requestToEvict; - if (this.cachedKeysByAge.length === this.sizeLimit) { - requestToEvict = this.cachedKeysByAge.shift(); - delete this.cache[requestToEvict]; - } - this.cache[url] = resp; - this.cachedKeysByAge.push(url); - } - }); - return RequestCache; - }(); var Transport = function() { - var pendingRequestsCount = 0, pendingRequests = {}, maxPendingRequests, requestCache; + var pendingRequestsCount = 0, pendingRequests = {}, maxPendingRequests = 6, requestCache = new LruCache(10); function Transport(o) { - utils.bindAll(this); - o = utils.isString(o) ? { - url: o - } : o; - requestCache = requestCache || new RequestCache(); - maxPendingRequests = utils.isNumber(o.maxParallelRequests) ? o.maxParallelRequests : maxPendingRequests || 6; - this.url = o.url; - this.wildcard = o.wildcard || "%QUERY"; - this.filter = o.filter; - this.replace = o.replace; - this.ajaxSettings = { - type: "get", - cache: o.cache, - timeout: o.timeout, - dataType: o.dataType || "json", - beforeSend: o.beforeSend - }; - this._get = (/^throttle$/i.test(o.rateLimitFn) ? utils.throttle : utils.debounce)(this._get, o.rateLimitWait || 300); + o = o || {}; + this._send = o.send ? callbackToDeferred(o.send) : $.ajax; + this._get = o.rateLimiter ? o.rateLimiter(this._get) : this._get; } - utils.mixin(Transport.prototype, { - _get: function(url, cb) { - var that = this; - if (belowPendingRequestsThreshold()) { - this._sendRequest(url).done(done); + Transport.setMaxPendingRequests = function setMaxPendingRequests(num) { + maxPendingRequests = num; + }; + Transport.resetCache = function clearCache() { + requestCache = new LruCache(10); + }; + _.mixin(Transport.prototype, { + _get: function(url, o, cb) { + var that = this, jqXhr; + if (jqXhr = pendingRequests[url]) { + jqXhr.done(done); + } else if (pendingRequestsCount < maxPendingRequests) { + pendingRequestsCount++; + pendingRequests[url] = this._send(url, o).done(done).always(always); } else { this.onDeckRequestArgs = [].slice.call(arguments, 0); } function done(resp) { - var data = that.filter ? that.filter(resp) : resp; - cb && cb(data); + cb && cb(resp); requestCache.set(url, resp); } - }, - _sendRequest: function(url) { - var that = this, jqXhr = pendingRequests[url]; - if (!jqXhr) { - incrementPendingRequests(); - jqXhr = pendingRequests[url] = $.ajax(url, this.ajaxSettings).always(always); - } - return jqXhr; function always() { - decrementPendingRequests(); - pendingRequests[url] = null; + pendingRequestsCount--; + delete pendingRequests[url]; if (that.onDeckRequestArgs) { that._get.apply(that, that.onDeckRequestArgs); that.onDeckRequestArgs = null; } } }, - get: function(query, cb) { - var that = this, encodedQuery = encodeURIComponent(query || ""), url, resp; - cb = cb || utils.noop; - url = this.replace ? this.replace(this.url, encodedQuery) : this.url.replace(this.wildcard, encodedQuery); + get: function(url, o, cb) { + var that = this, resp; + if (_.isFunction(o)) { + cb = o; + o = {}; + } if (resp = requestCache.get(url)) { - utils.defer(function() { - cb(that.filter ? that.filter(resp) : resp); + _.defer(function() { + cb && cb(resp); }); } else { - this._get(url, cb); + this._get(url, o, cb); } return !!resp; } }); return Transport; - function incrementPendingRequests() { - pendingRequestsCount++; - } - function decrementPendingRequests() { - pendingRequestsCount--; - } - function belowPendingRequestsThreshold() { - return pendingRequestsCount < maxPendingRequests; + function callbackToDeferred(fn) { + return function customSendWrapper(url, o) { + var deferred = $.Deferred(); + fn(url, o, onSuccess, onError); + return deferred; + function onSuccess(resp) { + _.defer(function() { + deferred.resolve(resp); + }); + } + function onError(err) { + _.defer(function() { + deferred.reject(err); + }); + } + }; } }(); - var Dataset = function() { - var keys = { - thumbprint: "thumbprint", - protocol: "protocol", - itemHash: "itemHash", - adjacencyList: "adjacencyList" - }; - function Dataset(o) { - utils.bindAll(this); - if (utils.isString(o.template) && !o.engine) { - $.error("no template engine specified"); + var SearchIndex = function() { + function SearchIndex(o) { + o = o || {}; + if (!o.datumTokenizer || !o.queryTokenizer) { + $.error("datumTokenizer and queryTokenizer are both required"); } - if (!o.local && !o.prefetch && !o.remote) { - $.error("one of local, prefetch, or remote is required"); - } - this.name = o.name || utils.getUniqueId(); - this.limit = o.limit || 5; - this.minLength = o.minLength || 1; - this.header = o.header; - this.footer = o.footer; - this.valueKey = o.valueKey || "value"; - this.template = compileTemplate(o.template, o.engine, this.valueKey); - this.local = o.local; - this.prefetch = o.prefetch; - this.remote = o.remote; - this.itemHash = {}; - this.adjacencyList = {}; - this.storage = o.name ? new PersistentStorage(o.name) : null; + this.datumTokenizer = o.datumTokenizer; + this.queryTokenizer = o.queryTokenizer; + this.datums = []; + this.trie = newNode(); } - utils.mixin(Dataset.prototype, { - _processLocalData: function(data) { - this._mergeProcessedData(this._processData(data)); + _.mixin(SearchIndex.prototype, { + bootstrap: function bootstrap(o) { + this.datums = o.datums; + this.trie = o.trie; }, - _loadPrefetchData: function(o) { - var that = this, thumbprint = VERSION + (o.thumbprint || ""), storedThumbprint, storedProtocol, storedItemHash, storedAdjacencyList, isExpired, deferred; - if (this.storage) { - storedThumbprint = this.storage.get(keys.thumbprint); - storedProtocol = this.storage.get(keys.protocol); - storedItemHash = this.storage.get(keys.itemHash); - storedAdjacencyList = this.storage.get(keys.adjacencyList); - } - isExpired = storedThumbprint !== thumbprint || storedProtocol !== utils.getProtocol(); - o = utils.isString(o) ? { - url: o - } : o; - o.ttl = utils.isNumber(o.ttl) ? o.ttl : 24 * 60 * 60 * 1e3; - if (storedItemHash && storedAdjacencyList && !isExpired) { - this._mergeProcessedData({ - itemHash: storedItemHash, - adjacencyList: storedAdjacencyList - }); - deferred = $.Deferred().resolve(); - } else { - deferred = $.getJSON(o.url).done(processPrefetchData); - } - return deferred; - function processPrefetchData(data) { - var filteredData = o.filter ? o.filter(data) : data, processedData = that._processData(filteredData), itemHash = processedData.itemHash, adjacencyList = processedData.adjacencyList; - if (that.storage) { - that.storage.set(keys.itemHash, itemHash, o.ttl); - that.storage.set(keys.adjacencyList, adjacencyList, o.ttl); - that.storage.set(keys.thumbprint, thumbprint, o.ttl); - that.storage.set(keys.protocol, utils.getProtocol(), o.ttl); - } - that._mergeProcessedData(processedData); - } - }, - _transformDatum: function(datum) { - var value = utils.isString(datum) ? datum : datum[this.valueKey], tokens = datum.tokens || utils.tokenizeText(value), item = { - value: value, - tokens: tokens - }; - if (utils.isString(datum)) { - item.datum = {}; - item.datum[this.valueKey] = datum; - } else { - item.datum = datum; - } - item.tokens = utils.filter(item.tokens, function(token) { - return !utils.isBlankString(token); - }); - item.tokens = utils.map(item.tokens, function(token) { - return token.toLowerCase(); - }); - return item; - }, - _processData: function(data) { - var that = this, itemHash = {}, adjacencyList = {}; - utils.each(data, function(i, datum) { - var item = that._transformDatum(datum), id = utils.getUniqueId(item.value); - itemHash[id] = item; - utils.each(item.tokens, function(i, token) { - var character = token.charAt(0), adjacency = adjacencyList[character] || (adjacencyList[character] = [ id ]); - !~utils.indexOf(adjacency, id) && adjacency.push(id); - }); - }); - return { - itemHash: itemHash, - adjacencyList: adjacencyList - }; - }, - _mergeProcessedData: function(processedData) { + add: function(data) { var that = this; - utils.mixin(this.itemHash, processedData.itemHash); - utils.each(processedData.adjacencyList, function(character, adjacency) { - var masterAdjacency = that.adjacencyList[character]; - that.adjacencyList[character] = masterAdjacency ? masterAdjacency.concat(adjacency) : adjacency; + data = _.isArray(data) ? data : [ data ]; + _.each(data, function(datum) { + var id, tokens; + id = that.datums.push(datum) - 1; + tokens = normalizeTokens(that.datumTokenizer(datum)); + _.each(tokens, function(token) { + var node, chars, ch, ids; + node = that.trie; + chars = token.split(""); + while (ch = chars.shift()) { + node = node.children[ch] || (node.children[ch] = newNode()); + node.ids.push(id); + } + }); }); }, - _getLocalSuggestions: function(terms) { - var that = this, firstChars = [], lists = [], shortestList, suggestions = []; - utils.each(terms, function(i, term) { - var firstChar = term.charAt(0); - !~utils.indexOf(firstChars, firstChar) && firstChars.push(firstChar); - }); - utils.each(firstChars, function(i, firstChar) { - var list = that.adjacencyList[firstChar]; - if (!list) { + get: function get(query) { + var that = this, tokens, matches; + tokens = normalizeTokens(this.queryTokenizer(query)); + _.each(tokens, function(token) { + var node, chars, ch, ids; + if (matches && matches.length === 0) { return false; } - lists.push(list); - if (!shortestList || list.length < shortestList.length) { - shortestList = list; + node = that.trie; + chars = token.split(""); + while (node && (ch = chars.shift())) { + node = node.children[ch]; + } + if (node && chars.length === 0) { + ids = node.ids.slice(0); + matches = matches ? getIntersection(matches, ids) : ids; + } else { + matches = []; + return false; } }); - if (lists.length < firstChars.length) { - return []; - } - utils.each(shortestList, function(i, id) { - var item = that.itemHash[id], isCandidate, isMatch; - isCandidate = utils.every(lists, function(list) { - return ~utils.indexOf(list, id); - }); - isMatch = isCandidate && utils.every(terms, function(term) { - return utils.some(item.tokens, function(token) { - return token.indexOf(term) === 0; - }); - }); - isMatch && suggestions.push(item); - }); - return suggestions; + return matches ? _.map(unique(matches), function(id) { + return that.datums[id]; + }) : []; }, - initialize: function() { - var deferred; - this.local && this._processLocalData(this.local); - this.transport = this.remote ? new Transport(this.remote) : null; - deferred = this.prefetch ? this._loadPrefetchData(this.prefetch) : $.Deferred().resolve(); - this.local = this.prefetch = this.remote = null; - this.initialize = function() { - return deferred; + serialize: function serialize() { + return { + datums: this.datums, + trie: this.trie }; - return deferred; - }, - getSuggestions: function(query, cb) { - var that = this, terms, suggestions, cacheHit = false; - if (query.length < this.minLength) { - return; - } - terms = utils.tokenizeQuery(query); - suggestions = this._getLocalSuggestions(terms).slice(0, this.limit); - if (suggestions.length < this.limit && this.transport) { - cacheHit = this.transport.get(query, processRemoteData); - } - !cacheHit && cb && cb(suggestions); - function processRemoteData(data) { - suggestions = suggestions.slice(0); - utils.each(data, function(i, datum) { - var item = that._transformDatum(datum), isDuplicate; - isDuplicate = utils.some(suggestions, function(suggestion) { - return item.value === suggestion.value; - }); - !isDuplicate && suggestions.push(item); - return suggestions.length < that.limit; - }); - cb && cb(suggestions); - } } }); - return Dataset; - function compileTemplate(template, engine, valueKey) { - var renderFn, compiledTemplate; - if (utils.isFunction(template)) { - renderFn = template; - } else if (utils.isString(template)) { - compiledTemplate = engine.compile(template); - renderFn = utils.bind(compiledTemplate.render, compiledTemplate); - } else { - renderFn = function(context) { - return "

" + context[valueKey] + "

"; - }; + return SearchIndex; + function normalizeTokens(tokens) { + tokens = _.filter(tokens, function(token) { + return !!token; + }); + tokens = _.map(tokens, function(token) { + return token.toLowerCase(); + }); + return tokens; + } + function newNode() { + return { + ids: [], + children: {} + }; + } + function unique(array) { + var seen = {}, uniques = []; + for (var i = 0; i < array.length; i++) { + if (!seen[array[i]]) { + seen[array[i]] = true; + uniques.push(array[i]); + } + } + return uniques; + } + function getIntersection(arrayA, arrayB) { + var ai = 0, bi = 0, intersection = []; + arrayA = arrayA.sort(compare); + arrayB = arrayB.sort(compare); + while (ai < arrayA.length && bi < arrayB.length) { + if (arrayA[ai] < arrayB[bi]) { + ai++; + } else if (arrayA[ai] > arrayB[bi]) { + bi++; + } else { + intersection.push(arrayA[ai]); + ai++; + bi++; + } + } + return intersection; + function compare(a, b) { + return a - b; } - return renderFn; } }(); - var InputView = function() { - function InputView(o) { - var that = this; - utils.bindAll(this); - this.specialKeyCodeMap = { - 9: "tab", - 27: "esc", - 37: "left", - 39: "right", - 13: "enter", - 38: "up", - 40: "down" + var oParser = function() { + return { + local: getLocal, + prefetch: getPrefetch, + remote: getRemote + }; + function getLocal(o) { + return o.local || null; + } + function getPrefetch(o) { + var prefetch, defaults; + defaults = { + url: null, + thumbprint: "", + ttl: 24 * 60 * 60 * 1e3, + filter: null, + ajax: {} }; + if (prefetch = o.prefetch || null) { + prefetch = _.isString(prefetch) ? { + url: prefetch + } : prefetch; + prefetch = _.mixin(defaults, prefetch); + prefetch.thumbprint = VERSION + prefetch.thumbprint; + prefetch.ajax.method = prefetch.ajax.method || "get"; + prefetch.ajax.dataType = prefetch.ajax.dataType || "json"; + !prefetch.url && $.error("prefetch requires url to be set"); + } + return prefetch; + } + function getRemote(o) { + var remote, defaults; + defaults = { + url: null, + wildcard: "%QUERY", + replace: null, + rateLimitBy: "debounce", + rateLimitWait: 300, + send: null, + filter: null, + ajax: {} + }; + if (remote = o.remote || null) { + remote = _.isString(remote) ? { + url: remote + } : remote; + remote = _.mixin(defaults, remote); + remote.rateLimiter = /^throttle$/i.test(remote.rateLimitBy) ? byThrottle(remote.rateLimitWait) : byDebounce(remote.rateLimitWait); + remote.ajax.method = remote.ajax.method || "get"; + remote.ajax.dataType = remote.ajax.dataType || "json"; + delete remote.rateLimitBy; + delete remote.rateLimitWait; + !remote.url && $.error("remote requires url to be set"); + } + return remote; + function byDebounce(wait) { + return function(fn) { + return _.debounce(fn, wait); + }; + } + function byThrottle(wait) { + return function(fn) { + return _.throttle(fn, wait); + }; + } + } + }(); + var Bloodhound = window.Bloodhound = function() { + var keys; + keys = { + data: "data", + protocol: "protocol", + thumbprint: "thumbprint" + }; + function Bloodhound(o) { + if (!o || !o.local && !o.prefetch && !o.remote) { + $.error("one of local, prefetch, or remote is required"); + } + this.limit = o.limit || 5; + this.sorter = o.sorter || noSort; + this.dupDetector = o.dupDetector || ignoreDuplicates; + this.local = oParser.local(o); + this.prefetch = oParser.prefetch(o); + this.remote = oParser.remote(o); + this.cacheKey = this.prefetch ? this.prefetch.cacheKey || this.prefetch.url : null; + this.index = new SearchIndex({ + datumTokenizer: o.datumTokenizer, + queryTokenizer: o.queryTokenizer + }); + this.storage = this.cacheKey ? new PersistentStorage(this.cacheKey) : null; + } + Bloodhound.tokenizers = { + whitespace: function whitespaceTokenizer(s) { + return s.split(/\s+/); + }, + nonword: function nonwordTokenizer(s) { + return s.split(/\W+/); + } + }; + _.mixin(Bloodhound.prototype, { + _loadPrefetch: function loadPrefetch(o) { + var that = this, serialized, deferred; + if (serialized = this._readFromStorage(o.thumbprint)) { + this.index.bootstrap(serialized); + deferred = $.Deferred().resolve(); + } else { + deferred = $.ajax(o.url, o.ajax).done(handlePrefetchResponse); + } + return deferred; + function handlePrefetchResponse(resp) { + var filtered; + filtered = o.filter ? o.filter(resp) : resp; + that.add(filtered); + that._saveToStorage(that.index.serialize(), o.thumbprint, o.ttl); + } + }, + _getFromRemote: function getFromRemote(query, cb) { + var that = this, url, uriEncodedQuery; + query = query || ""; + uriEncodedQuery = encodeURIComponent(query); + url = this.remote.replace ? this.remote.replace(this.remote.url, query) : this.remote.url.replace(this.remote.wildcard, uriEncodedQuery); + return this.transport.get(url, this.remote.ajax, handleRemoteResponse); + function handleRemoteResponse(resp) { + var filtered = that.remote.filter ? that.remote.filter(resp) : resp; + cb(filtered); + } + }, + _saveToStorage: function saveToStorage(data, thumbprint, ttl) { + if (this.storage) { + this.storage.set(keys.data, data, ttl); + this.storage.set(keys.protocol, location.protocol, ttl); + this.storage.set(keys.thumbprint, thumbprint, ttl); + } + }, + _readFromStorage: function readFromStorage(thumbprint) { + var stored = {}; + if (this.storage) { + stored.data = this.storage.get(keys.data); + stored.protocol = this.storage.get(keys.protocol); + stored.thumbprint = this.storage.get(keys.thumbprint); + } + isExpired = stored.thumbprint !== thumbprint || stored.protocol !== location.protocol; + return stored.data && !isExpired ? stored.data : null; + }, + initialize: function initialize() { + var that = this, deferred; + deferred = this.prefetch ? this._loadPrefetch(this.prefetch) : $.Deferred().resolve(); + this.local && deferred.done(addLocalToIndex); + this.transport = this.remote ? new Transport(this.remote) : null; + this.initialize = function initialize() { + return deferred.promise(); + }; + return deferred.promise(); + function addLocalToIndex() { + that.add(that.local); + } + }, + add: function add(data) { + this.index.add(data); + }, + get: function get(query, cb) { + var that = this, matches, cacheHit = false; + matches = this.index.get(query).sort(this.sorter).slice(0, this.limit); + if (matches.length < this.limit && this.transport) { + cacheHit = this._getFromRemote(query, returnRemoteMatches); + } + !cacheHit && cb && cb(matches); + function returnRemoteMatches(remoteMatches) { + var matchesWithBackfill = matches.slice(0); + _.each(remoteMatches, function(remoteMatch) { + var isDuplicate; + isDuplicate = _.some(matchesWithBackfill, function(match) { + return that.dupDetector(remoteMatch, match); + }); + !isDuplicate && matchesWithBackfill.push(remoteMatch); + return matchesWithBackfill.length < that.limit; + }); + cb && cb(matchesWithBackfill.sort(that.sorter)); + } + }, + ttAdapter: function ttAdapter() { + return _.bind(this.get, this); + } + }); + return Bloodhound; + function noSort() { + return 0; + } + function ignoreDuplicates() { + return false; + } + }(); + var html = { + wrapper: '', + dropdown: '', + dataset: '
', + suggestions: '', + suggestion: '
%BODY%
' + }; + var css = { + wrapper: { + position: "relative", + display: "inline-block" + }, + hint: { + position: "absolute", + top: "0", + left: "0", + borderColor: "transparent", + boxShadow: "none" + }, + input: { + position: "relative", + verticalAlign: "top", + backgroundColor: "transparent" + }, + inputWithNoHint: { + position: "relative", + verticalAlign: "top" + }, + dropdown: { + position: "absolute", + top: "100%", + left: "0", + zIndex: "100", + display: "none" + }, + suggestions: { + display: "block" + }, + suggestion: { + whiteSpace: "nowrap", + cursor: "pointer" + }, + suggestionChild: { + whiteSpace: "normal" + }, + ltr: { + left: "0", + right: "auto" + }, + rtl: { + left: "auto", + right: " 0" + } + }; + if (_.isMsie()) { + _.mixin(css.input, { + backgroundImage: "url(data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)" + }); + } + if (_.isMsie() && _.isMsie() <= 7) { + _.mixin(css.input, { + marginTop: "-1px" + }); + } + var EventBus = function() { + var namespace = "typeahead:"; + function EventBus(o) { + if (!o || !o.el) { + $.error("EventBus initialized without el"); + } + this.$el = $(o.el); + } + _.mixin(EventBus.prototype, { + trigger: function(type) { + var args = [].slice.call(arguments, 1); + this.$el.trigger(namespace + type, args); + } + }); + return EventBus; + }(); + var EventEmitter = function() { + var splitter = /\s+/, nextTick = getNextTick(); + return { + onSync: onSync, + onAsync: onAsync, + off: off, + trigger: trigger + }; + function on(method, types, cb, context) { + var type; + if (!cb) { + return this; + } + types = types.split(splitter); + cb = context ? bindContext(cb, context) : cb; + this._callbacks = this._callbacks || {}; + while (type = types.shift()) { + this._callbacks[type] = this._callbacks[type] || { + sync: [], + async: [] + }; + this._callbacks[type][method].push(cb); + } + return this; + } + function onAsync(types, cb, context) { + return on.call(this, "async", types, cb, context); + } + function onSync(types, cb, context) { + return on.call(this, "sync", types, cb, context); + } + function off(types) { + var type; + if (!this._callbacks) { + return this; + } + types = types.split(splitter); + while (type = types.shift()) { + delete this._callbacks[type]; + } + return this; + } + function trigger(types) { + var that = this, type, callbacks, args, syncFlush, asyncFlush; + if (!this._callbacks) { + return this; + } + types = types.split(splitter); + args = [].slice.call(arguments, 1); + while ((type = types.shift()) && (callbacks = this._callbacks[type])) { + syncFlush = getFlush(callbacks.sync, this, [ type ].concat(args)); + asyncFlush = getFlush(callbacks.async, this, [ type ].concat(args)); + syncFlush() && nextTick(asyncFlush); + } + return this; + } + function getFlush(callbacks, context, args) { + return flush; + function flush() { + var cancelled; + for (var i = 0; !cancelled && i < callbacks.length; i += 1) { + cancelled = callbacks[i].apply(context, args) === false; + } + return !cancelled; + } + } + function getNextTick() { + var nextTickFn, messageChannel; + if (window.setImmediate) { + nextTickFn = function nextTickSetImmediate(fn) { + setImmediate(function() { + fn(); + }); + }; + } else { + nextTickFn = function nextTickSetTimeout(fn) { + setTimeout(function() { + fn(); + }, 0); + }; + } + return nextTickFn; + } + function bindContext(fn, context) { + return fn.bind ? fn.bind(context) : function() { + fn.apply(context, [].slice.call(arguments, 0)); + }; + } + }(); + var highlight = function(doc) { + var defaults = { + node: null, + pattern: null, + tagName: "strong", + className: null, + wordsOnly: false, + caseSensitive: false + }; + return function hightlight(o) { + var regex; + o = _.mixin({}, defaults, o); + if (!o.node || !o.pattern) { + return; + } + o.pattern = _.isArray(o.pattern) ? o.pattern : [ o.pattern ]; + regex = getRegex(o.pattern, o.caseSensitive, o.wordsOnly); + traverse(o.node, hightlightTextNode); + function hightlightTextNode(textNode) { + var match, patternNode; + if (match = regex.exec(textNode.data)) { + wrapperNode = doc.createElement(o.tagName); + o.className && (wrapperNode.className = o.className); + patternNode = textNode.splitText(match.index); + patternNode.splitText(match[0].length); + wrapperNode.appendChild(patternNode.cloneNode(true)); + textNode.parentNode.replaceChild(wrapperNode, patternNode); + } + return !!match; + } + function traverse(el, hightlightTextNode) { + var childNode, TEXT_NODE_TYPE = 3; + for (var i = 0; i < el.childNodes.length; i++) { + childNode = el.childNodes[i]; + if (childNode.nodeType === TEXT_NODE_TYPE) { + i += hightlightTextNode(childNode) ? 1 : 0; + } else { + traverse(childNode, hightlightTextNode); + } + } + } + }; + function getRegex(patterns, caseSensitive, wordsOnly) { + var escapedPatterns = [], regexStr; + for (var i = 0; i < patterns.length; i++) { + escapedPatterns.push(_.escapeRegExChars(patterns[i])); + } + regexStr = wordsOnly ? "\\b(" + escapedPatterns.join("|") + ")\\b" : "(" + escapedPatterns.join("|") + ")"; + return caseSensitive ? new RegExp(regexStr) : new RegExp(regexStr, "i"); + } + }(window.document); + var Input = function() { + var specialKeyCodeMap; + specialKeyCodeMap = { + 9: "tab", + 27: "esc", + 37: "left", + 39: "right", + 13: "enter", + 38: "up", + 40: "down" + }; + function Input(o) { + var that = this, onBlur, onFocus, onKeydown, onInput; + o = o || {}; + if (!o.input) { + $.error("input is missing"); + } + onBlur = _.bind(this._onBlur, this); + onFocus = _.bind(this._onFocus, this); + onKeydown = _.bind(this._onKeydown, this); + onInput = _.bind(this._onInput, this); this.$hint = $(o.hint); - this.$input = $(o.input).on("blur.tt", this._handleBlur).on("focus.tt", this._handleFocus).on("keydown.tt", this._handleSpecialKeyEvent); - if (!utils.isMsie()) { - this.$input.on("input.tt", this._compareQueryToInputValue); + this.$input = $(o.input).on("blur.tt", onBlur).on("focus.tt", onFocus).on("keydown.tt", onKeydown); + if (this.$hint.length === 0) { + this.setHintValue = this.getHintValue = this.clearHint = _.noop; + } + if (!_.isMsie()) { + this.$input.on("input.tt", onInput); } else { this.$input.on("keydown.tt keypress.tt cut.tt paste.tt", function($e) { - if (that.specialKeyCodeMap[$e.which || $e.keyCode]) { + if (specialKeyCodeMap[$e.which || $e.keyCode]) { return; } - utils.defer(that._compareQueryToInputValue); + _.defer(_.bind(that._onInput, that, $e)); }); } this.query = this.$input.val(); this.$overflowHelper = buildOverflowHelper(this.$input); } - utils.mixin(InputView.prototype, EventTarget, { - _handleFocus: function() { + Input.normalizeQuery = function(str) { + return (str || "").replace(/^\s*/g, "").replace(/\s{2,}/g, " "); + }; + _.mixin(Input.prototype, EventEmitter, { + _onBlur: function onBlur($e) { + this.resetInputValue(); + this.trigger("blurred"); + }, + _onFocus: function onFocus($e) { this.trigger("focused"); }, - _handleBlur: function() { - this.trigger("blured"); - }, - _handleSpecialKeyEvent: function($e) { - var keyName = this.specialKeyCodeMap[$e.which || $e.keyCode]; - keyName && this.trigger(keyName + "Keyed", $e); - }, - _compareQueryToInputValue: function() { - var inputValue = this.getInputValue(), isSameQuery = compareQueries(this.query, inputValue), isSameQueryExceptWhitespace = isSameQuery ? this.query.length !== inputValue.length : false; - if (isSameQueryExceptWhitespace) { - this.trigger("whitespaceChanged", { - value: this.query - }); - } else if (!isSameQuery) { - this.trigger("queryChanged", { - value: this.query = inputValue - }); + _onKeydown: function onKeydown($e) { + var keyName = specialKeyCodeMap[$e.which || $e.keyCode]; + this._managePreventDefault(keyName, $e); + if (keyName && this._shouldTrigger(keyName, $e)) { + this.trigger(keyName + "Keyed", $e); } }, - destroy: function() { - this.$hint.off(".tt"); - this.$input.off(".tt"); - this.$hint = this.$input = this.$overflowHelper = null; + _onInput: function onInput($e) { + this._checkInputValue(); }, - focus: function() { + _managePreventDefault: function managePreventDefault(keyName, $e) { + var preventDefault, hintValue, inputValue; + switch (keyName) { + case "tab": + hintValue = this.getHintValue(); + inputValue = this.getInputValue(); + preventDefault = hintValue && hintValue !== inputValue && !withModifier($e); + break; + + case "up": + case "down": + preventDefault = !withModifier($e); + break; + + default: + preventDefault = false; + } + preventDefault && $e.preventDefault(); + }, + _shouldTrigger: function shouldTrigger(keyName, $e) { + var trigger; + switch (keyName) { + case "tab": + trigger = !withModifier($e); + break; + + default: + trigger = true; + } + return trigger; + }, + _checkInputValue: function checkInputValue() { + var inputValue, areEquivalent, hasDifferentWhitespace; + inputValue = this.getInputValue(); + areEquivalent = areQueriesEquivalent(inputValue, this.query); + hasDifferentWhitespace = areEquivalent ? this.query.length !== inputValue.length : false; + if (!areEquivalent) { + this.trigger("queryChanged", this.query = inputValue); + } else if (hasDifferentWhitespace) { + this.trigger("whitespaceChanged", this.query); + } + }, + focus: function focus() { this.$input.focus(); }, - blur: function() { + blur: function blur() { this.$input.blur(); }, - getQuery: function() { + getQuery: function getQuery() { return this.query; }, - setQuery: function(query) { + setQuery: function setQuery(query) { this.query = query; }, - getInputValue: function() { + getInputValue: function getInputValue() { return this.$input.val(); }, - setInputValue: function(value, silent) { + setInputValue: function setInputValue(value, silent) { this.$input.val(value); - !silent && this._compareQueryToInputValue(); + !silent && this._checkInputValue(); }, - getHintValue: function() { + getHintValue: function getHintValue() { return this.$hint.val(); }, - setHintValue: function(value) { + setHintValue: function setHintValue(value) { this.$hint.val(value); }, - getLanguageDirection: function() { + resetInputValue: function resetInputValue() { + this.$input.val(this.query); + }, + clearHint: function clearHint() { + this.$hint.val(""); + }, + getLanguageDirection: function getLanguageDirection() { return (this.$input.css("direction") || "ltr").toLowerCase(); }, - isOverflow: function() { + hasOverflow: function hasOverflow() { + var constraint = this.$input.width() - 2; this.$overflowHelper.text(this.getInputValue()); - return this.$overflowHelper.width() > this.$input.width(); + return this.$overflowHelper.width() >= constraint; }, isCursorAtEnd: function() { - var valueLength = this.$input.val().length, selectionStart = this.$input[0].selectionStart, range; - if (utils.isNumber(selectionStart)) { + var valueLength, selectionStart, range; + valueLength = this.$input.val().length; + selectionStart = this.$input[0].selectionStart; + if (_.isNumber(selectionStart)) { return selectionStart === valueLength; } else if (document.selection) { range = document.selection.createRange(); @@ -658,13 +1010,17 @@ return valueLength === range.text.length; } return true; + }, + destroy: function destroy() { + this.$hint.off(".tt"); + this.$input.off(".tt"); + this.$hint = this.$input = this.$overflowHelper = null; } }); - return InputView; + return Input; function buildOverflowHelper($input) { - return $("").css({ + return $('').css({ position: "absolute", - left: "-9999px", visibility: "hidden", whiteSpace: "nowrap", fontFamily: $input.css("font-family"), @@ -679,452 +1035,597 @@ textTransform: $input.css("text-transform") }).insertAfter($input); } - function compareQueries(a, b) { - a = (a || "").replace(/^\s*/g, "").replace(/\s{2,}/g, " "); - b = (b || "").replace(/^\s*/g, "").replace(/\s{2,}/g, " "); - return a === b; + function areQueriesEquivalent(a, b) { + return Input.normalizeQuery(a) === Input.normalizeQuery(b); + } + function withModifier($e) { + return $e.altKey || $e.ctrlKey || $e.metaKey || $e.shiftKey; } }(); - var DropdownView = function() { - var html = { - suggestionsList: '' - }, css = { - suggestionsList: { - display: "block" - }, - suggestion: { - whiteSpace: "nowrap", - cursor: "pointer" - }, - suggestionChild: { - whiteSpace: "normal" + var Dataset = function() { + var datasetKey = "ttDataset", valueKey = "ttValue", datumKey = "ttDatum"; + function Dataset(o) { + o = o || {}; + o.templates = o.templates || {}; + if (!o.source) { + $.error("missing source"); } + this.query = null; + this.highlight = !!o.highlight; + this.name = o.name || _.getUniqueId(); + this.source = o.source; + this.valueKey = o.displayKey || "value"; + this.templates = getTemplates(o.templates, this.valueKey); + this.$el = $(html.dataset.replace("%CLASS%", this.name)); + } + Dataset.extractDatasetName = function extractDatasetName(el) { + return $(el).data(datasetKey); }; - function DropdownView(o) { - utils.bindAll(this); + Dataset.extractValue = function extractDatum(el) { + return $(el).data(valueKey); + }; + Dataset.extractDatum = function extractDatum(el) { + return $(el).data(datumKey); + }; + _.mixin(Dataset.prototype, EventEmitter, { + _render: function render(query, suggestions) { + if (!this.$el) { + return; + } + var that = this, hasSuggestions; + this.$el.empty(); + hasSuggestions = suggestions && suggestions.length; + if (!hasSuggestions && this.templates.empty) { + this.$el.html(getEmptyHtml()).prepend(that.templates.header ? getHeaderHtml() : null).append(that.templates.footer ? getFooterHtml() : null); + } else if (hasSuggestions) { + this.$el.html(getSuggestionsHtml()).prepend(that.templates.header ? getHeaderHtml() : null).append(that.templates.footer ? getFooterHtml() : null); + } + this.trigger("rendered"); + function getEmptyHtml() { + return that.templates.empty({ + query: query + }); + } + function getSuggestionsHtml() { + var $suggestions; + $suggestions = $(html.suggestions).css(css.suggestions).append(_.map(suggestions, getSuggestionNode)); + that.highlight && highlight({ + node: $suggestions[0], + pattern: query + }); + return $suggestions; + function getSuggestionNode(suggestion) { + var $el, innerHtml, outerHtml; + innerHtml = that.templates.suggestion(suggestion); + outerHtml = html.suggestion.replace("%BODY%", innerHtml); + $el = $(outerHtml).data(datasetKey, that.name).data(valueKey, suggestion[that.valueKey]).data(datumKey, suggestion); + $el.children().each(function() { + $(this).css(css.suggestionChild); + }); + return $el; + } + } + function getHeaderHtml() { + return that.templates.header({ + query: query, + isEmpty: !hasSuggestions + }); + } + function getFooterHtml() { + return that.templates.footer({ + query: query, + isEmpty: !hasSuggestions + }); + } + }, + getRoot: function getRoot() { + return this.$el; + }, + update: function update(query) { + var that = this; + this.query = query; + this.source(query, renderIfQueryIsSame); + function renderIfQueryIsSame(suggestions) { + query === that.query && that._render(query, suggestions); + } + }, + clear: function clear() { + this._render(this.query || ""); + }, + isEmpty: function isEmpty() { + return this.$el.is(":empty"); + }, + destroy: function destroy() { + this.$el = null; + } + }); + return Dataset; + function getTemplates(templates, valueKey) { + return { + empty: templates.empty && _.templatify(templates.empty), + header: templates.header && _.templatify(templates.header), + footer: templates.footer && _.templatify(templates.footer), + suggestion: templates.suggestion || suggestionTemplate + }; + function suggestionTemplate(context) { + return "

" + context[valueKey] + "

"; + } + } + }(); + var Dropdown = function() { + function Dropdown(o) { + var that = this, onMouseEnter, onMouseLeave, onSuggestionClick, onSuggestionMouseEnter, onSuggestionMouseLeave; + o = o || {}; + if (!o.menu) { + $.error("menu is required"); + } this.isOpen = false; this.isEmpty = true; this.isMouseOverDropdown = false; - this.$menu = $(o.menu).on("mouseenter.tt", this._handleMouseenter).on("mouseleave.tt", this._handleMouseleave).on("click.tt", ".tt-suggestion", this._handleSelection).on("mouseover.tt", ".tt-suggestion", this._handleMouseover); + this.datasets = _.map(o.datasets, initializeDataset); + onMouseEnter = _.bind(this._onMouseEnter, this); + onMouseLeave = _.bind(this._onMouseLeave, this); + onSuggestionClick = _.bind(this._onSuggestionClick, this); + onSuggestionMouseEnter = _.bind(this._onSuggestionMouseEnter, this); + onSuggestionMouseLeave = _.bind(this._onSuggestionMouseLeave, this); + this.$menu = $(o.menu).on("mouseenter.tt", onMouseEnter).on("mouseleave.tt", onMouseLeave).on("click.tt", ".tt-suggestion", onSuggestionClick).on("mouseenter.tt", ".tt-suggestion", onSuggestionMouseEnter).on("mouseleave.tt", ".tt-suggestion", onSuggestionMouseLeave); + _.each(this.datasets, function(dataset) { + that.$menu.append(dataset.getRoot()); + dataset.onSync("rendered", that._onRendered, that); + }); } - utils.mixin(DropdownView.prototype, EventTarget, { - _handleMouseenter: function() { + _.mixin(Dropdown.prototype, EventEmitter, { + _onMouseEnter: function onMouseEnter($e) { this.isMouseOverDropdown = true; }, - _handleMouseleave: function() { + _onMouseLeave: function onMouseLeave($e) { this.isMouseOverDropdown = false; }, - _handleMouseover: function($e) { - var $suggestion = $($e.currentTarget); - this._getSuggestions().removeClass("tt-is-under-cursor"); - $suggestion.addClass("tt-is-under-cursor"); + _onSuggestionClick: function onSuggestionClick($e) { + this.trigger("suggestionClicked", $($e.currentTarget)); }, - _handleSelection: function($e) { - var $suggestion = $($e.currentTarget); - this.trigger("suggestionSelected", extractSuggestion($suggestion)); + _onSuggestionMouseEnter: function onSuggestionMouseEnter($e) { + this._removeCursor(); + this._setCursor($($e.currentTarget), true); }, - _show: function() { - this.$menu.css("display", "block"); + _onSuggestionMouseLeave: function onSuggestionMouseLeave($e) { + this._removeCursor(); + }, + _onRendered: function onRendered() { + this.isEmpty = _.every(this.datasets, isDatasetEmpty); + this.isEmpty ? this._hide() : this.isOpen && this._show(); + this.trigger("datasetRendered"); + function isDatasetEmpty(dataset) { + return dataset.isEmpty(); + } }, _hide: function() { this.$menu.hide(); }, - _moveCursor: function(increment) { - var $suggestions, $cur, nextIndex, $underCursor; - if (!this.isVisible()) { + _show: function() { + this.$menu.css("display", "block"); + }, + _getSuggestions: function getSuggestions() { + return this.$menu.find(".tt-suggestion"); + }, + _getCursor: function getCursor() { + return this.$menu.find(".tt-cursor").first(); + }, + _setCursor: function setCursor($el, silent) { + $el.first().addClass("tt-cursor"); + !silent && this.trigger("cursorMoved"); + }, + _removeCursor: function removeCursor() { + this._getCursor().removeClass("tt-cursor"); + }, + _moveCursor: function moveCursor(increment) { + var $suggestions, $oldCursor, newCursorIndex, $newCursor; + if (!this.isOpen) { return; } + $oldCursor = this._getCursor(); $suggestions = this._getSuggestions(); - $cur = $suggestions.filter(".tt-is-under-cursor"); - $cur.removeClass("tt-is-under-cursor"); - nextIndex = $suggestions.index($cur) + increment; - nextIndex = (nextIndex + 1) % ($suggestions.length + 1) - 1; - if (nextIndex === -1) { + this._removeCursor(); + newCursorIndex = $suggestions.index($oldCursor) + increment; + newCursorIndex = (newCursorIndex + 1) % ($suggestions.length + 1) - 1; + if (newCursorIndex === -1) { this.trigger("cursorRemoved"); return; - } else if (nextIndex < -1) { - nextIndex = $suggestions.length - 1; + } else if (newCursorIndex < -1) { + newCursorIndex = $suggestions.length - 1; } - $underCursor = $suggestions.eq(nextIndex).addClass("tt-is-under-cursor"); - this._ensureVisibility($underCursor); - this.trigger("cursorMoved", extractSuggestion($underCursor)); + this._setCursor($newCursor = $suggestions.eq(newCursorIndex)); + this._ensureVisible($newCursor); }, - _getSuggestions: function() { - return this.$menu.find(".tt-suggestions > .tt-suggestion"); - }, - _ensureVisibility: function($el) { - var menuHeight = this.$menu.height() + parseInt(this.$menu.css("paddingTop"), 10) + parseInt(this.$menu.css("paddingBottom"), 10), menuScrollTop = this.$menu.scrollTop(), elTop = $el.position().top, elBottom = elTop + $el.outerHeight(true); + _ensureVisible: function ensureVisible($el) { + var elTop, elBottom, menuScrollTop, menuHeight; + elTop = $el.position().top; + elBottom = elTop + $el.outerHeight(true); + menuScrollTop = this.$menu.scrollTop(); + menuHeight = this.$menu.height() + parseInt(this.$menu.css("paddingTop"), 10) + parseInt(this.$menu.css("paddingBottom"), 10); if (elTop < 0) { this.$menu.scrollTop(menuScrollTop + elTop); } else if (menuHeight < elBottom) { this.$menu.scrollTop(menuScrollTop + (elBottom - menuHeight)); } }, - destroy: function() { - this.$menu.off(".tt"); - this.$menu = null; - }, - isVisible: function() { - return this.isOpen && !this.isEmpty; - }, - closeUnlessMouseIsOverDropdown: function() { - if (!this.isMouseOverDropdown) { - this.close(); - } - }, - close: function() { + close: function close() { if (this.isOpen) { - this.isOpen = false; - this.isMouseOverDropdown = false; + this.isOpen = this.isMouseOverDropdown = false; + this._removeCursor(); this._hide(); - this.$menu.find(".tt-suggestions > .tt-suggestion").removeClass("tt-is-under-cursor"); this.trigger("closed"); } }, - open: function() { + open: function open() { if (!this.isOpen) { this.isOpen = true; !this.isEmpty && this._show(); this.trigger("opened"); } }, - setLanguageDirection: function(dir) { - var ltrCss = { - left: "0", - right: "auto" - }, rtlCss = { - left: "auto", - right: " 0" - }; - dir === "ltr" ? this.$menu.css(ltrCss) : this.$menu.css(rtlCss); + setLanguageDirection: function setLanguageDirection(dir) { + this.$menu.css(dir === "ltr" ? css.ltr : css.rtl); }, - moveCursorUp: function() { + moveCursorUp: function moveCursorUp() { this._moveCursor(-1); }, - moveCursorDown: function() { + moveCursorDown: function moveCursorDown() { this._moveCursor(+1); }, - getSuggestionUnderCursor: function() { - var $suggestion = this._getSuggestions().filter(".tt-is-under-cursor").first(); - return $suggestion.length > 0 ? extractSuggestion($suggestion) : null; - }, - getFirstSuggestion: function() { - var $suggestion = this._getSuggestions().first(); - return $suggestion.length > 0 ? extractSuggestion($suggestion) : null; - }, - renderSuggestions: function(dataset, suggestions) { - var datasetClassName = "tt-dataset-" + dataset.name, wrapper = '
%body
', compiledHtml, $suggestionsList, $dataset = this.$menu.find("." + datasetClassName), elBuilder, fragment, $el; - if ($dataset.length === 0) { - $suggestionsList = $(html.suggestionsList).css(css.suggestionsList); - $dataset = $("
").addClass(datasetClassName).append(dataset.header).append($suggestionsList).append(dataset.footer).appendTo(this.$menu); + getDatumForSuggestion: function getDatumForSuggestion($el) { + var datum = null; + if ($el.length) { + datum = { + raw: Dataset.extractDatum($el), + value: Dataset.extractValue($el), + datasetName: Dataset.extractDatasetName($el) + }; } - if (suggestions.length > 0) { - this.isEmpty = false; - this.isOpen && this._show(); - elBuilder = document.createElement("div"); - fragment = document.createDocumentFragment(); - utils.each(suggestions, function(i, suggestion) { - suggestion.dataset = dataset.name; - compiledHtml = dataset.template(suggestion.datum); - elBuilder.innerHTML = wrapper.replace("%body", compiledHtml); - $el = $(elBuilder.firstChild).css(css.suggestion).data("suggestion", suggestion); - $el.children().each(function() { - $(this).css(css.suggestionChild); - }); - fragment.appendChild($el[0]); - }); - $dataset.show().find(".tt-suggestions").html(fragment); - } else { - this.clearSuggestions(dataset.name); - } - this.trigger("suggestionsRendered"); + return datum; }, - clearSuggestions: function(datasetName) { - var $datasets = datasetName ? this.$menu.find(".tt-dataset-" + datasetName) : this.$menu.find('[class^="tt-dataset-"]'), $suggestions = $datasets.find(".tt-suggestions"); - $datasets.hide(); - $suggestions.empty(); - if (this._getSuggestions().length === 0) { - this.isEmpty = true; - this._hide(); + getDatumForCursor: function getDatumForCursor() { + return this.getDatumForSuggestion(this._getCursor().first()); + }, + getDatumForTopSuggestion: function getDatumForTopSuggestion() { + return this.getDatumForSuggestion(this._getSuggestions().first()); + }, + update: function update(query) { + _.each(this.datasets, updateDataset); + function updateDataset(dataset) { + dataset.update(query); + } + }, + empty: function empty() { + _.each(this.datasets, clearDataset); + function clearDataset(dataset) { + dataset.clear(); + } + }, + isVisible: function isVisible() { + return this.isOpen && !this.isEmpty; + }, + destroy: function destroy() { + this.$menu.off(".tt"); + this.$menu = null; + _.each(this.datasets, destroyDataset); + function destroyDataset(dataset) { + dataset.destroy(); } } }); - return DropdownView; - function extractSuggestion($el) { - return $el.data("suggestion"); + return Dropdown; + function initializeDataset(oDataset) { + return new Dataset(oDataset); } }(); - var TypeaheadView = function() { - var html = { - wrapper: '', - hint: '', - dropdown: '' - }, css = { - wrapper: { - position: "relative", - display: "inline-block" - }, - hint: { - position: "absolute", - top: "0", - left: "0", - borderColor: "transparent", - boxShadow: "none" - }, - query: { - position: "relative", - verticalAlign: "top", - backgroundColor: "transparent" - }, - dropdown: { - position: "absolute", - top: "100%", - left: "0", - zIndex: "100", - display: "none" + var Typeahead = function() { + var attrsKey = "ttAttrs"; + function Typeahead(o) { + var $menu, $input, $hint, datasets; + o = o || {}; + if (!o.input) { + $.error("missing input"); } - }; - if (utils.isMsie()) { - utils.mixin(css.query, { - backgroundImage: "url(data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)" - }); - } - if (utils.isMsie() && utils.isMsie() <= 7) { - utils.mixin(css.wrapper, { - display: "inline", - zoom: "1" - }); - utils.mixin(css.query, { - marginTop: "-1px" - }); - } - function TypeaheadView(o) { - var $menu, $input, $hint; - utils.bindAll(this); - this.$node = buildDomStructure(o.input); - this.datasets = o.datasets; - this.dir = null; - this.eventBus = o.eventBus; + this.autoselect = !!o.autoselect; + this.minLength = _.isNumber(o.minLength) ? o.minLength : 1; + this.$node = buildDomStructure(o.input, o.withHint); $menu = this.$node.find(".tt-dropdown-menu"); - $input = this.$node.find(".tt-query"); + $input = this.$node.find(".tt-input"); $hint = this.$node.find(".tt-hint"); - this.dropdownView = new DropdownView({ - menu: $menu - }).on("suggestionSelected", this._handleSelection).on("cursorMoved", this._clearHint).on("cursorMoved", this._setInputValueToSuggestionUnderCursor).on("cursorRemoved", this._setInputValueToQuery).on("cursorRemoved", this._updateHint).on("suggestionsRendered", this._updateHint).on("opened", this._updateHint).on("closed", this._clearHint).on("opened closed", this._propagateEvent); - this.inputView = new InputView({ + this.eventBus = o.eventBus || new EventBus({ + el: $input + }); + this.dropdown = new Dropdown({ + menu: $menu, + datasets: o.datasets + }).onSync("suggestionClicked", this._onSuggestionClicked, this).onSync("cursorMoved", this._onCursorMoved, this).onSync("cursorRemoved", this._onCursorRemoved, this).onSync("opened", this._onOpened, this).onSync("closed", this._onClosed, this).onAsync("datasetRendered", this._onDatasetRendered, this); + this.input = new Input({ input: $input, hint: $hint - }).on("focused", this._openDropdown).on("blured", this._closeDropdown).on("blured", this._setInputValueToQuery).on("enterKeyed tabKeyed", this._handleSelection).on("queryChanged", this._clearHint).on("queryChanged", this._clearSuggestions).on("queryChanged", this._getSuggestions).on("whitespaceChanged", this._updateHint).on("queryChanged whitespaceChanged", this._openDropdown).on("queryChanged whitespaceChanged", this._setLanguageDirection).on("escKeyed", this._closeDropdown).on("escKeyed", this._setInputValueToQuery).on("tabKeyed upKeyed downKeyed", this._managePreventDefault).on("upKeyed downKeyed", this._moveDropdownCursor).on("upKeyed downKeyed", this._openDropdown).on("tabKeyed leftKeyed rightKeyed", this._autocomplete); - } - utils.mixin(TypeaheadView.prototype, EventTarget, { - _managePreventDefault: function(e) { - var $e = e.data, hint, inputValue, preventDefault = false; - switch (e.type) { - case "tabKeyed": - hint = this.inputView.getHintValue(); - inputValue = this.inputView.getInputValue(); - preventDefault = hint && hint !== inputValue; - break; - - case "upKeyed": - case "downKeyed": - preventDefault = !$e.shiftKey && !$e.ctrlKey && !$e.metaKey; - break; + }).onSync("focused", this._onFocused, this).onSync("blurred", this._onBlurred, this).onSync("enterKeyed", this._onEnterKeyed, this).onSync("tabKeyed", this._onTabKeyed, this).onSync("escKeyed", this._onEscKeyed, this).onSync("upKeyed", this._onUpKeyed, this).onSync("downKeyed", this._onDownKeyed, this).onSync("leftKeyed", this._onLeftKeyed, this).onSync("rightKeyed", this._onRightKeyed, this).onSync("queryChanged", this._onQueryChanged, this).onSync("whitespaceChanged", this._onWhitespaceChanged, this); + $menu.on("mousedown.tt", function($e) { + if (_.isMsie() && _.isMsie() < 9) { + $input[0].onbeforedeactivate = function() { + window.event.returnValue = false; + $input[0].onbeforedeactivate = null; + }; + } + $e.preventDefault(); + }); + } + _.mixin(Typeahead.prototype, { + _onSuggestionClicked: function onSuggestionClicked(type, $el) { + var datum; + if (datum = this.dropdown.getDatumForSuggestion($el)) { + this._select(datum); } - preventDefault && $e.preventDefault(); }, - _setLanguageDirection: function() { - var dir = this.inputView.getLanguageDirection(); - if (dir !== this.dir) { + _onCursorMoved: function onCursorMoved() { + var datum = this.dropdown.getDatumForCursor(); + this.input.clearHint(); + this.input.setInputValue(datum.value, true); + this.eventBus.trigger("cursorchanged", datum.raw, datum.datasetName); + }, + _onCursorRemoved: function onCursorRemoved() { + this.input.resetInputValue(); + this._updateHint(); + }, + _onDatasetRendered: function onDatasetRendered() { + this._updateHint(); + }, + _onOpened: function onOpened() { + this._updateHint(); + this.eventBus.trigger("opened"); + }, + _onClosed: function onClosed() { + this.input.clearHint(); + this.eventBus.trigger("closed"); + }, + _onFocused: function onFocused() { + this.dropdown.open(); + }, + _onBlurred: function onBlurred() { + !this.dropdown.isMouseOverDropdown && this.dropdown.close(); + }, + _onEnterKeyed: function onEnterKeyed(type, $e) { + var cursorDatum, topSuggestionDatum; + cursorDatum = this.dropdown.getDatumForCursor(); + topSuggestionDatum = this.dropdown.getDatumForTopSuggestion(); + if (cursorDatum) { + this._select(cursorDatum); + $e.preventDefault(); + } else if (this.autoselect && topSuggestionDatum) { + this._select(topSuggestionDatum); + $e.preventDefault(); + } + }, + _onTabKeyed: function onTabKeyed(type, $e) { + var datum; + if (datum = this.dropdown.getDatumForCursor()) { + this._select(datum); + $e.preventDefault(); + } else { + this._autocomplete(); + } + }, + _onEscKeyed: function onEscKeyed() { + this.dropdown.close(); + this.input.resetInputValue(); + }, + _onUpKeyed: function onUpKeyed() { + var query = this.input.getQuery(); + if (!this.dropdown.isOpen && query.length >= this.minLength) { + this.dropdown.update(query); + } + this.dropdown.open(); + this.dropdown.moveCursorUp(); + }, + _onDownKeyed: function onDownKeyed() { + var query = this.input.getQuery(); + if (!this.dropdown.isOpen && query.length >= this.minLength) { + this.dropdown.update(query); + } + this.dropdown.open(); + this.dropdown.moveCursorDown(); + }, + _onLeftKeyed: function onLeftKeyed() { + this.dir === "rtl" && this._autocomplete(); + }, + _onRightKeyed: function onRightKeyed() { + this.dir === "ltr" && this._autocomplete(); + }, + _onQueryChanged: function onQueryChanged(e, query) { + this.input.clearHint(); + this.dropdown.empty(); + query.length >= this.minLength && this.dropdown.update(query); + this.dropdown.open(); + this._setLanguageDirection(); + }, + _onWhitespaceChanged: function onWhitespaceChanged() { + this._updateHint(); + this.dropdown.open(); + }, + _setLanguageDirection: function setLanguageDirection() { + var dir; + if (this.dir !== (dir = this.input.getLanguageDirection())) { this.dir = dir; this.$node.css("direction", dir); - this.dropdownView.setLanguageDirection(dir); + this.dropdown.setLanguageDirection(dir); } }, - _updateHint: function() { - var suggestion = this.dropdownView.getFirstSuggestion(), hint = suggestion ? suggestion.value : null, dropdownIsVisible = this.dropdownView.isVisible(), inputHasOverflow = this.inputView.isOverflow(), inputValue, query, escapedQuery, beginsWithQuery, match; - if (hint && dropdownIsVisible && !inputHasOverflow) { - inputValue = this.inputView.getInputValue(); - query = inputValue.replace(/\s{2,}/g, " ").replace(/^\s+/g, ""); - escapedQuery = utils.escapeRegExChars(query); - beginsWithQuery = new RegExp("^(?:" + escapedQuery + ")(.*$)", "i"); - match = beginsWithQuery.exec(hint); - this.inputView.setHintValue(inputValue + (match ? match[1] : "")); + _updateHint: function updateHint() { + var datum, inputValue, query, escapedQuery, frontMatchRegEx, match; + datum = this.dropdown.getDatumForTopSuggestion(); + if (datum && this.dropdown.isVisible() && !this.input.hasOverflow()) { + inputValue = this.input.getInputValue(); + query = Input.normalizeQuery(inputValue); + escapedQuery = _.escapeRegExChars(query); + frontMatchRegEx = new RegExp("^(?:" + escapedQuery + ")(.*$)", "i"); + match = frontMatchRegEx.exec(datum.value); + this.input.setHintValue(inputValue + (match ? match[1] : "")); } }, - _clearHint: function() { - this.inputView.setHintValue(""); - }, - _clearSuggestions: function() { - this.dropdownView.clearSuggestions(); - }, - _setInputValueToQuery: function() { - this.inputView.setInputValue(this.inputView.getQuery()); - }, - _setInputValueToSuggestionUnderCursor: function(e) { - var suggestion = e.data; - this.inputView.setInputValue(suggestion.value, true); - }, - _openDropdown: function() { - this.dropdownView.open(); - }, - _closeDropdown: function(e) { - this.dropdownView[e.type === "blured" ? "closeUnlessMouseIsOverDropdown" : "close"](); - }, - _moveDropdownCursor: function(e) { - var $e = e.data; - if (!$e.shiftKey && !$e.ctrlKey && !$e.metaKey) { - this.dropdownView[e.type === "upKeyed" ? "moveCursorUp" : "moveCursorDown"](); + _autocomplete: function autocomplete() { + var hint, query, datum; + hint = this.input.getHintValue(); + query = this.input.getQuery(); + if (hint && query !== hint && this.input.isCursorAtEnd()) { + datum = this.dropdown.getDatumForTopSuggestion(); + datum && this.input.setInputValue(datum.value); + this.eventBus.trigger("autocompleted", datum.raw, datum.datasetName); } }, - _handleSelection: function(e) { - var byClick = e.type === "suggestionSelected", suggestion = byClick ? e.data : this.dropdownView.getSuggestionUnderCursor(); - if (suggestion) { - this.inputView.setInputValue(suggestion.value); - byClick ? this.inputView.focus() : e.data.preventDefault(); - byClick && utils.isMsie() ? utils.defer(this.dropdownView.close) : this.dropdownView.close(); - this.eventBus.trigger("selected", suggestion.datum, suggestion.dataset); - } + _select: function select(datum) { + this.input.clearHint(); + this.input.setQuery(datum.value); + this.input.setInputValue(datum.value, true); + this.dropdown.empty(); + this._setLanguageDirection(); + _.defer(_.bind(this.dropdown.close, this.dropdown)); + this.eventBus.trigger("selected", datum.raw, datum.datasetName); }, - _getSuggestions: function() { - var that = this, query = this.inputView.getQuery(); - if (utils.isBlankString(query)) { - return; - } - utils.each(this.datasets, function(i, dataset) { - dataset.getSuggestions(query, function(suggestions) { - if (query === that.inputView.getQuery()) { - that.dropdownView.renderSuggestions(dataset, suggestions); - } - }); - }); + open: function open() { + this.dropdown.open(); }, - _autocomplete: function(e) { - var isCursorAtEnd, ignoreEvent, query, hint, suggestion; - if (e.type === "rightKeyed" || e.type === "leftKeyed") { - isCursorAtEnd = this.inputView.isCursorAtEnd(); - ignoreEvent = this.inputView.getLanguageDirection() === "ltr" ? e.type === "leftKeyed" : e.type === "rightKeyed"; - if (!isCursorAtEnd || ignoreEvent) { - return; - } - } - query = this.inputView.getQuery(); - hint = this.inputView.getHintValue(); - if (hint !== "" && query !== hint) { - suggestion = this.dropdownView.getFirstSuggestion(); - this.inputView.setInputValue(suggestion.value); - this.eventBus.trigger("autocompleted", suggestion.datum, suggestion.dataset); - } + close: function close() { + this.dropdown.close(); }, - _propagateEvent: function(e) { - this.eventBus.trigger(e.type); + getQuery: function getQuery() { + return this.input.getQuery(); }, - destroy: function() { - this.inputView.destroy(); - this.dropdownView.destroy(); + setQuery: function setQuery(val) { + this.input.setInputValue(val); + }, + destroy: function destroy() { + this.input.destroy(); + this.dropdown.destroy(); destroyDomStructure(this.$node); this.$node = null; - }, - setQuery: function(query) { - this.inputView.setQuery(query); - this.inputView.setInputValue(query); - this._clearHint(); - this._clearSuggestions(); - this._getSuggestions(); } }); - return TypeaheadView; - function buildDomStructure(input) { - var $wrapper = $(html.wrapper), $dropdown = $(html.dropdown), $input = $(input), $hint = $(html.hint); - $wrapper = $wrapper.css(css.wrapper); - $dropdown = $dropdown.css(css.dropdown); - $hint.css(css.hint).css({ - backgroundAttachment: $input.css("background-attachment"), - backgroundClip: $input.css("background-clip"), - backgroundColor: $input.css("background-color"), - backgroundImage: $input.css("background-image"), - backgroundOrigin: $input.css("background-origin"), - backgroundPosition: $input.css("background-position"), - backgroundRepeat: $input.css("background-repeat"), - backgroundSize: $input.css("background-size") + return Typeahead; + function buildDomStructure(input, withHint) { + var $input, $wrapper, $dropdown, $hint; + $input = $(input); + $wrapper = $(html.wrapper).css(css.wrapper); + $dropdown = $(html.dropdown).css(css.dropdown); + $hint = $input.clone().css(css.hint).css(getBackgroundStyles($input)); + $hint.removeData().addClass("tt-hint").removeAttr("id name placeholder").prop("disabled", true).attr({ + autocomplete: "off", + spellcheck: "false" }); - $input.data("ttAttrs", { + $input.data(attrsKey, { dir: $input.attr("dir"), autocomplete: $input.attr("autocomplete"), spellcheck: $input.attr("spellcheck"), style: $input.attr("style") }); - $input.addClass("tt-query").attr({ + $input.addClass("tt-input").attr({ autocomplete: "off", spellcheck: false - }).css(css.query); + }).css(withHint ? css.input : css.inputWithNoHint); try { !$input.attr("dir") && $input.attr("dir", "auto"); } catch (e) {} - return $input.wrap($wrapper).parent().prepend($hint).append($dropdown); + return $input.wrap($wrapper).parent().prepend(withHint ? $hint : null).append($dropdown); + } + function getBackgroundStyles($el) { + return { + backgroundAttachment: $el.css("background-attachment"), + backgroundClip: $el.css("background-clip"), + backgroundColor: $el.css("background-color"), + backgroundImage: $el.css("background-image"), + backgroundOrigin: $el.css("background-origin"), + backgroundPosition: $el.css("background-position"), + backgroundRepeat: $el.css("background-repeat"), + backgroundSize: $el.css("background-size") + }; } function destroyDomStructure($node) { - var $input = $node.find(".tt-query"); - utils.each($input.data("ttAttrs"), function(key, val) { - utils.isUndefined(val) ? $input.removeAttr(key) : $input.attr(key, val); + var $input = $node.find(".tt-input"); + _.each($input.data(attrsKey), function(val, key) { + _.isUndefined(val) ? $input.removeAttr(key) : $input.attr(key, val); }); - $input.detach().removeData("ttAttrs").removeClass("tt-query").insertAfter($node); + $input.detach().removeData(attrsKey).removeClass("tt-input").insertAfter($node); $node.remove(); } }(); (function() { - var cache = {}, viewKey = "ttView", methods; + var typeaheadKey, methods; + typeaheadKey = "ttTypeahead"; methods = { - initialize: function(datasetDefs) { - var datasets; - datasetDefs = utils.isArray(datasetDefs) ? datasetDefs : [ datasetDefs ]; - if (datasetDefs.length === 0) { - $.error("no datasets provided"); - } - datasets = utils.map(datasetDefs, function(o) { - var dataset = cache[o.name] ? cache[o.name] : new Dataset(o); - if (o.name) { - cache[o.name] = dataset; - } - return dataset; - }); - return this.each(initialize); - function initialize() { - var $input = $(this), deferreds, eventBus = new EventBus({ - el: $input + initialize: function initialize(o) { + var datasets = [].slice.call(arguments, 1); + o = o || {}; + return this.each(attach); + function attach() { + var $input = $(this), eventBus, typeahead; + _.each(datasets, function(d) { + d.highlight = !!o.highlight; }); - deferreds = utils.map(datasets, function(dataset) { - return dataset.initialize(); - }); - $input.data(viewKey, new TypeaheadView({ + typeahead = new Typeahead({ input: $input, eventBus: eventBus = new EventBus({ el: $input }), + withHint: _.isUndefined(o.hint) ? true : !!o.hint, + minLength: o.minLength, + autoselect: o.autoselect, datasets: datasets - })); - $.when.apply($, deferreds).always(function() { - utils.defer(function() { - eventBus.trigger("initialized"); - }); }); - } - }, - destroy: function() { - return this.each(destroy); - function destroy() { - var $this = $(this), view = $this.data(viewKey); - if (view) { - view.destroy(); - $this.removeData(viewKey); + $input.data(typeaheadKey, typeahead); + function trigger(eventName) { + return function() { + _.defer(function() { + eventBus.trigger(eventName); + }); + }; } } }, - setQuery: function(query) { - return this.each(setQuery); + open: function open() { + return this.each(openTypeahead); + function openTypeahead() { + var $input = $(this), typeahead; + if (typeahead = $input.data(typeaheadKey)) { + typeahead.open(); + } + } + }, + close: function close() { + return this.each(closeTypeahead); + function closeTypeahead() { + var $input = $(this), typeahead; + if (typeahead = $input.data(typeaheadKey)) { + typeahead.close(); + } + } + }, + val: function val(newVal) { + return _.isString(newVal) ? this.each(setQuery) : this.map(getQuery).get(); function setQuery() { - var view = $(this).data(viewKey); - view && view.setQuery(query); + var $input = $(this), typeahead; + if (typeahead = $input.data(typeaheadKey)) { + typeahead.setQuery(newVal); + } + } + function getQuery() { + var $input = $(this), typeahead, query; + if (typeahead = $input.data(typeaheadKey)) { + query = typeahead.getQuery(); + } + return query; + } + }, + destroy: function destroy() { + return this.each(unattach); + function unattach() { + var $input = $(this), typeahead; + if (typeahead = $input.data(typeaheadKey)) { + typeahead.destroy(); + $input.removeData(typeaheadKey); + } } } }; diff --git a/extensions/gii/components/ActiveField.php b/extensions/gii/components/ActiveField.php index b6c77ca6e0..24399fb8d1 100644 --- a/extensions/gii/components/ActiveField.php +++ b/extensions/gii/components/ActiveField.php @@ -63,7 +63,10 @@ class ActiveField extends \yii\widgets\ActiveField { static $counter = 0; $this->inputOptions['class'] .= ' typeahead-' . (++$counter); - $this->form->getView()->registerJs("jQuery('.typeahead-{$counter}').typeahead({local: " . Json::encode($data) . "});"); + foreach ($data as &$item) { + $item = array('word' => $item); + } + $this->form->getView()->registerJs("yii.gii.autocomplete($counter, " . Json::encode($data) . ");"); return $this; } } diff --git a/framework/CHANGELOG.md b/framework/CHANGELOG.md index b19ac09664..7da4dbe074 100644 --- a/framework/CHANGELOG.md +++ b/framework/CHANGELOG.md @@ -44,6 +44,7 @@ Yii Framework 2 Change Log - Bug #2212: `yii\gridview\DataColumn` generates incorrect labels when used with nosql DB and there is no data (qiangxue) - Bug #2298: Fixed the bug that Gii controller generator did not allow digit in the controller ID (qiangxue) - Bug #2303: Fixed the bug that `yii\base\Theme::pathMap` did not support dynamic update with path aliases (qiangxue) +- Bug #2324: Fixed QueryBuilder bug when building a query with "query" option (mintao) - Bug: Fixed `Call to a member function registerAssetFiles() on a non-object` in case of wrong `sourcePath` for an asset bundle (samdark) - Bug: Fixed incorrect event name for `yii\jui\Spinner` (samdark) - Bug: Json::encode() did not handle objects that implement JsonSerializable interface correctly (cebe) @@ -162,6 +163,7 @@ Yii Framework 2 Change Log - Chg: Renamed `yii\web\Request::acceptedLanguages` to `acceptableLanguages` (qiangxue) - Chg: Removed implementation of `Arrayable` from `yii\Object` (qiangxue) - Chg: Renamed `ActiveRecordInterface::createActiveRelation()` to `createRelation()` (qiangxue) +- Chg: The scripts in asset bundles are now registered in `View` at the end of `endBody()`. It was done in `endPage()` previously (qiangxue) - New #66: [Auth client library](https://github.com/yiisoft/yii2-authclient) OpenId, OAuth1, OAuth2 clients (klimov-paul) - New #706: Added `yii\widgets\Pjax` and enhanced `GridView` to work with `Pjax` to support AJAX-update (qiangxue) - New #1393: [Codeception testing framework integration](https://github.com/yiisoft/yii2-codeception) (Ragazzo) diff --git a/framework/assets/yii.gridView.js b/framework/assets/yii.gridView.js index 1913f5ee78..496e3cdea7 100644 --- a/framework/assets/yii.gridView.js +++ b/framework/assets/yii.gridView.js @@ -61,17 +61,23 @@ applyFilter: function () { var $grid = $(this); var settings = $grid.data('yiiGridView').settings; - var data = $(settings.filterSelector).serialize(); - var url = settings.filterUrl; - if (url.indexOf('?') >= 0) { - url += '&' + data; - } else { - url += '?' + data; - } + var data = {}; + $.each($(settings.filterSelector).serializeArray(), function () { + data[this.name] = this.value; + }); + + $.each(yii.getQueryParams(settings.filterUrl), function (name, value) { + if (data[name] === undefined) { + data[name] = value; + } + }); + + var pos = settings.filterUrl.indexOf('?'); + var url = pos < 0 ? settings.filterUrl : settings.filterUrl.substring(0, pos); $grid.find('form.gridview-filter-form').remove(); var $form = $('').appendTo($grid); - $.each(yii.getQueryParams(url), function (name, value) { + $.each(data, function (name, value) { $form.append($('').attr('name', name).val(value)); }); $form.submit(); diff --git a/framework/console/controllers/FixtureController.php b/framework/console/controllers/FixtureController.php index 67f2700700..f23ee211d9 100644 --- a/framework/console/controllers/FixtureController.php +++ b/framework/console/controllers/FixtureController.php @@ -222,7 +222,7 @@ class FixtureController extends Controller */ private function notifyUnloaded($fixtures) { - $this->stdout("Fixtures were successfully loaded from namespace:\n", Console::FG_YELLOW); + $this->stdout("Fixtures were successfully unloaded from namespace:\n", Console::FG_YELLOW); $this->stdout("\t\"" . Yii::getAlias($this->namespace) . "\"\n\n", Console::FG_GREEN); $this->outputList($fixtures); } diff --git a/framework/db/Query.php b/framework/db/Query.php index 9a42009495..4fb30a4d5a 100644 --- a/framework/db/Query.php +++ b/framework/db/Query.php @@ -95,7 +95,10 @@ class Query extends Component implements QueryInterface public $having; /** * @var array this is used to construct the UNION clause(s) in a SQL statement. - * Each array element can be either a string or a [[Query]] object representing a sub-query. + * Each array element is an array of the following structure: + * + * - `query`: either a string or a [[Query]] object representing a query + * - `all`: boolean, whether it should be `UNION ALL` or `UNION` */ public $union; /** diff --git a/framework/db/QueryBuilder.php b/framework/db/QueryBuilder.php index 9a20cb3145..0b5d850622 100644 --- a/framework/db/QueryBuilder.php +++ b/framework/db/QueryBuilder.php @@ -767,8 +767,11 @@ class QueryBuilder extends \yii\base\Object if ($query instanceof Query) { // save the original parameters so that we can restore them later to prevent from modifying the query object $originalParams = $query->params; - $query->addParams($params); - list ($unions[$i]['query'], $params) = $this->build($query); + $command = $query->createCommand($this->db); + $unions[$i]['query'] = $command->sql; + foreach ($command->params as $name => $value) { + $params[$name] = $value; + } $query->params = $originalParams; } @@ -1107,11 +1110,18 @@ class QueryBuilder extends \yii\base\Object * @param array $operands contains only one element which is a [[Query]] object representing the sub-query. * @param array $params the binding parameters to be populated * @return string the generated SQL expression + * @throws InvalidParamException if the operand is not a [[Query]] object. */ public function buildExistsCondition($operator, $operands, &$params) { $subQuery = $operands[0]; - list($subQuerySql, $subQueryParams) = $this->build($subQuery); + if (!$subQuery instanceof Query) { + throw new InvalidParamException('Subquery for EXISTS operator must be a Query object.'); + } + + $command = $subQuery->createCommand($this->db); + $subQuerySql = $command->sql; + $subQueryParams = $command->params; if (!empty($subQueryParams)) { foreach ($subQueryParams as $name => $value) { $params[$name] = $value; diff --git a/framework/web/View.php b/framework/web/View.php index 41c1c56286..eb83bc7985 100644 --- a/framework/web/View.php +++ b/framework/web/View.php @@ -127,6 +127,58 @@ class View extends \yii\base\View private $_assetManager; + + /** + * Marks the position of an HTML head section. + */ + public function head() + { + echo self::PH_HEAD; + } + + /** + * Marks the beginning of an HTML body section. + */ + public function beginBody() + { + echo self::PH_BODY_BEGIN; + $this->trigger(self::EVENT_BEGIN_BODY); + } + + /** + * Marks the ending of an HTML body section. + */ + public function endBody() + { + $this->trigger(self::EVENT_END_BODY); + echo self::PH_BODY_END; + + foreach (array_keys($this->assetBundles) as $bundle) { + $this->registerAssetFiles($bundle); + } + } + + /** + * Marks the ending of an HTML page. + * @param boolean $ajaxMode whether the view is rendering in AJAX mode. + * If true, the JS scripts registered at [[POS_READY]] and [[POS_LOAD]] positions + * will be rendered at the end of the view like normal scripts. + */ + public function endPage($ajaxMode = false) + { + $this->trigger(self::EVENT_END_PAGE); + + $content = ob_get_clean(); + + echo strtr($content, [ + self::PH_HEAD => $this->renderHeadHtml(), + self::PH_BODY_BEGIN => $this->renderBodyBeginHtml(), + self::PH_BODY_END => $this->renderBodyEndHtml($ajaxMode), + ]); + + $this->clear(); + } + /** * Renders a view in response to an AJAX request. * @@ -177,29 +229,6 @@ class View extends \yii\base\View $this->_assetManager = $value; } - /** - * Marks the ending of an HTML page. - * @param boolean $ajaxMode whether the view is rendering in AJAX mode. - * If true, the JS scripts registered at [[POS_READY]] and [[POS_LOAD]] positions - * will be rendered at the end of the view like normal scripts. - */ - public function endPage($ajaxMode = false) - { - $this->trigger(self::EVENT_END_PAGE); - - $content = ob_get_clean(); - foreach (array_keys($this->assetBundles) as $bundle) { - $this->registerAssetFiles($bundle); - } - echo strtr($content, [ - self::PH_HEAD => $this->renderHeadHtml(), - self::PH_BODY_BEGIN => $this->renderBodyBeginHtml(), - self::PH_BODY_END => $this->renderBodyEndHtml($ajaxMode), - ]); - - $this->clear(); - } - /** * Clears up the registered meta tags, link tags, css/js scripts and files. */ @@ -233,32 +262,6 @@ class View extends \yii\base\View unset($this->assetBundles[$name]); } - /** - * Marks the beginning of an HTML body section. - */ - public function beginBody() - { - echo self::PH_BODY_BEGIN; - $this->trigger(self::EVENT_BEGIN_BODY); - } - - /** - * Marks the ending of an HTML body section. - */ - public function endBody() - { - $this->trigger(self::EVENT_END_BODY); - echo self::PH_BODY_END; - } - - /** - * Marks the position of an HTML head section. - */ - public function head() - { - echo self::PH_HEAD; - } - /** * Registers the named asset bundle. * All dependent asset bundles will be registered. diff --git a/framework/widgets/Pjax.php b/framework/widgets/Pjax.php index de41f15b41..5b25b41176 100644 --- a/framework/widgets/Pjax.php +++ b/framework/widgets/Pjax.php @@ -66,7 +66,9 @@ class Pjax extends Widget */ public $enableReplaceState = false; /** - * @var integer pjax timeout setting (in milliseconds) + * @var integer pjax timeout setting (in milliseconds). This timeout is used when making AJAX requests. + * Use a bigger number if your server is slow. If the server does not respond within the timeout, + * a full page load will be triggered. */ public $timeout = 1000; /** @@ -89,15 +91,17 @@ class Pjax extends Widget $this->options['id'] = $this->getId(); } - ob_start(); - ob_implicit_flush(false); - if ($this->requiresPjax()) { + ob_start(); + ob_implicit_flush(false); $view = $this->getView(); $view->clear(); $view->beginPage(); $view->head(); $view->beginBody(); + if ($view->title !== null) { + echo Html::tag('title', Html::encode($view->title)); + } } echo Html::beginTag('div', $this->options); } @@ -108,32 +112,37 @@ class Pjax extends Widget public function run() { echo Html::endTag('div'); - if ($requiresPjax = $this->requiresPjax()) { - $view = $this->getView(); - $view->endBody(); - $view->endPage(true); + + if (!$this->requiresPjax()) { + $this->registerClientScript(); + return; } + $view = $this->getView(); + $view->endBody(); + + // Do not re-send css files as it may override the css files that were loaded after them. + // This is a temporary fix for https://github.com/yiisoft/yii2/issues/2310 + // It should be removed once pjax supports loading only missing css files + $view->cssFiles = null; + + $view->endPage(true); + $content = ob_get_clean(); - if ($requiresPjax) { - // only need the content enclosed within this widget - $response = Yii::$app->getResponse(); - $level = ob_get_level(); - $response->clearOutputBuffers(); - $response->setStatusCode(200); - $response->format = Response::FORMAT_HTML; - $response->content = $content; - $response->send(); + // only need the content enclosed within this widget + $response = Yii::$app->getResponse(); + $level = ob_get_level(); + $response->clearOutputBuffers(); + $response->setStatusCode(200); + $response->format = Response::FORMAT_HTML; + $response->content = $content; + $response->send(); - // re-enable output buffer to capture content after this widget - for (; $level > 0; --$level) { - ob_start(); - ob_implicit_flush(false); - } - } else { - $this->registerClientScript(); - echo $content; + // re-enable output buffer to capture content after this widget + for (; $level > 0; --$level) { + ob_start(); + ob_implicit_flush(false); } } @@ -162,7 +171,7 @@ class Pjax extends Widget $view = $this->getView(); PjaxAsset::register($view); $js = "jQuery(document).pjax($linkSelector, \"#$id\", $options);"; - $js .= "\njQuery(document).on('submit', $formSelector, function (event) {jQuery.pjax.submit(event, '#$id');});"; + $js .= "\njQuery(document).on('submit', $formSelector, function (event) {jQuery.pjax.submit(event, '#$id', $options);});"; $view->registerJs($js); } } diff --git a/tests/unit/extensions/elasticsearch/QueryBuilderTest.php b/tests/unit/extensions/elasticsearch/QueryBuilderTest.php new file mode 100644 index 0000000000..9e4db0266b --- /dev/null +++ b/tests/unit/extensions/elasticsearch/QueryBuilderTest.php @@ -0,0 +1,77 @@ +getConnection()->createCommand(); + + // delete index + if ($command->indexExists('yiitest')) { + $command->deleteIndex('yiitest'); + } + } + + private function prepareDbData() + { + $command = $this->getConnection()->createCommand(); + $command->insert('yiitest', 'article', ['title' => 'I love yii!'], 1); + $command->insert('yiitest', 'article', ['title' => 'Symfony2 is another framework'], 2); + $command->insert('yiitest', 'article', ['title' => 'Yii2 out now!'], 3); + $command->insert('yiitest', 'article', ['title' => 'yii test'], 4); + + $command->flushIndex('yiitest'); + } + + public function testQueryBuilderRespectsQuery() + { + $queryParts = ['field' => ['title' => 'yii']]; + $queryBuilder = new QueryBuilder($this->getConnection()); + $query = new Query(); + $query->query = $queryParts; + $build = $queryBuilder->build($query); + $this->assertTrue(array_key_exists('queryParts', $build)); + $this->assertTrue(array_key_exists('query', $build['queryParts'])); + $this->assertFalse(array_key_exists('match_all', $build['queryParts']), 'Match all should not be set'); + $this->assertSame($queryParts, $build['queryParts']['query']); + } + + public function testYiiCanBeFoundByQuery() + { + $this->prepareDbData(); + $queryParts = ['field' => ['title' => 'yii']]; + $query = new Query(); + $query->from('yiitest', 'article'); + $query->query = $queryParts; + $result = $query->search($this->getConnection()); + $this->assertEquals(2, $result['hits']['total']); + } + + public function testFuzzySearch() + { + $this->prepareDbData(); + $queryParts = [ + "fuzzy_like_this" => [ + "fields" => ["title"], + "like_text" => "Similar to YII", + "max_query_terms" => 4 + ] + ]; + $query = new Query(); + $query->from('yiitest', 'article'); + $query->query = $queryParts; + $result = $query->search($this->getConnection()); + $this->assertEquals(3, $result['hits']['total']); + } +} +