/** * @requires javelin-install * javelin-util * javelin-dom * javelin-typeahead-normalizer * @provides javelin-typeahead-source * @javelin */ /** * @group control */ JX.install('TypeaheadSource', { construct : function() { this._raw = {}; this._lookup = {}; this.setNormalizer(JX.TypeaheadNormalizer.normalize); this._excludeIDs = {}; }, events : ['waiting', 'resultsready', 'complete'], properties : { /** * Allows you to specify a function which will be used to normalize strings. * Strings are normalized before being tokenized, and before being sent to * the server. The purpose of normalization is to strip out irrelevant data, * like uppercase/lowercase, extra spaces, or punctuation. By default, * the @{JX.TypeaheadNormalizer} is used to normalize strings, but you may * want to provide a different normalizer, particiularly if there are * special characters with semantic meaning in your object names. * * @param function */ normalizer : null, /** * If a typeahead query should be processed before being normalized and * tokenized, specify a queryExtractor. * * @param function */ queryExtractor : null, /** * Transformers convert data from a wire format to a runtime format. The * transformation mechanism allows you to choose an efficient wire format * and then expand it on the client side, rather than duplicating data * over the wire. The transformation is applied to objects passed to * addResult(). It should accept whatever sort of object you ship over the * wire, and produce a dictionary with these keys: * * - **id**: a unique id for each object. * - **name**: the string used for matching against user input. * - **uri**: the URI corresponding with the object (must be present * but need not be meaningful) * * You can also give: * - **display**: the text or nodes to show in the DOM. Usually just the * same as ##name##. * - **tokenizable**: if you want to tokenize something other than the * ##name##, for the typeahead to complete on, specify it here. A * selected entry from the typeahead will still insert the ##name## * into the input, but the ##tokenizable## field lets you complete on * non-name things. * * The default transformer expects a three element list with elements * [name, uri, id]. It assigns the first element to both ##name## and * ##display##. * * @param function */ transformer : null, /** * Configures the maximum number of suggestions shown in the typeahead * dropdown. * * @param int */ maximumResultCount : 5, /** * Optional function which is used to sort results. Inputs are the input * string, the list of matches, and a default comparator. The function * should sort the list for display. This is the minimum useful * implementation: * * function(value, list, comparator) { * list.sort(comparator); * } * * Alternatively, you may pursue more creative implementations. * * The `value` is a raw string; you can bind the datasource into the * function and use normalize() or tokenize() to parse it. * * The `list` is a list of objects returned from the transformer function, * see the `transformer` property. These are the objects in the list which * match the value. * * The `comparator` is a sort callback which implements sensible default * sorting rules (e.g., alphabetic order), which you can use as a fallback * if you just want to tweak the results (e.g., put some items at the top). * * The function is called after the user types some text, immediately before * the possible completion results are displayed to the user. * * @param function */ sortHandler : null }, members : { _raw : null, _lookup : null, _excludeIDs : null, _changeListener : null, _startListener : null, bindToTypeahead : function(typeahead) { this._changeListener = typeahead.listen( 'change', JX.bind(this, this.didChange) ); this._startListener = typeahead.listen( 'start', JX.bind(this, this.didStart) ); }, unbindFromTypeahead : function() { this._changeListener.remove(); this._startListener.remove(); }, didChange : function(value) { return; }, didStart : function() { return; }, clearCache : function() { this._raw = {}; this._lookup = {}; }, addExcludeID : function(id) { if (id) { this._excludeIDs[id] = true; } }, removeExcludeID : function (id) { if (id) { delete this._excludeIDs[id]; } }, addResult : function(obj) { obj = (this.getTransformer() || this._defaultTransformer)(obj); if (obj.id in this._raw) { // We're already aware of this result. This will happen if someone // searches for "zeb" and then for "zebra" with a // TypeaheadRequestSource, for example, or the datasource just doesn't // dedupe things properly. Whatever the case, just ignore it. return; } if (__DEV__) { for (var k in {name : 1, id : 1, display : 1, uri : 1}) { if (!(k in obj)) { throw new Error( "JX.TypeaheadSource.addResult(): " + "result must have properties 'name', 'id', 'uri' and 'display'."); } } } this._raw[obj.id] = obj; var t = this.tokenize(obj.tokenizable || obj.name); for (var jj = 0; jj < t.length; ++jj) { if (!this._lookup.hasOwnProperty(t[jj])) { this._lookup[t[jj]] = []; } this._lookup[t[jj]].push(obj.id); } }, waitForResults : function() { this.invoke('waiting'); return this; }, /** * Get the raw state of a result by its ID. A number of other events and * mechanisms give a list of result IDs and limited additional data; if you * need to act on the full result data you can look it up here. * * @param scalar Result ID. * @return dict Corresponding raw result. */ getResult : function(id) { return this._raw[id]; }, matchResults : function(value, partial) { // This table keeps track of the number of tokens each potential match // has actually matched. When we're done, the real matches are those // which have matched every token (so the value is equal to the token // list length). var match_count = {}; // This keeps track of distinct matches. If the user searches for // something like "Chris C" against "Chris Cox", the "C" will match // both fragments. We need to make sure we only count distinct matches. var match_fragments = {}; var matched = {}; var seen = {}; var query_extractor = this.getQueryExtractor(); if (query_extractor) { value = query_extractor(value); } var t = this.tokenize(value); // Sort tokens by longest-first. We match each name fragment with at // most one token. t.sort(function(u, v) { return v.length - u.length; }); for (var ii = 0; ii < t.length; ++ii) { // Do something reasonable if the user types the same token twice; this // is sort of stupid so maybe kill it? if (t[ii] in seen) { t.splice(ii--, 1); continue; } seen[t[ii]] = true; var fragment = t[ii]; for (var name_fragment in this._lookup) { if (name_fragment.substr(0, fragment.length) === fragment) { if (!(name_fragment in matched)) { matched[name_fragment] = true; } else { continue; } var l = this._lookup[name_fragment]; for (var jj = 0; jj < l.length; ++jj) { var match_id = l[jj]; if (!match_fragments[match_id]) { match_fragments[match_id] = {}; } if (!(fragment in match_fragments[match_id])) { match_fragments[match_id][fragment] = true; match_count[match_id] = (match_count[match_id] || 0) + 1; } } } } } var hits = []; for (var k in match_count) { if (match_count[k] == t.length && !this._excludeIDs[k]) { hits.push(k); } } this.sortHits(value, hits); var nodes = this.renderNodes(value, hits); this.invoke('resultsready', nodes); if (!partial) { this.invoke('complete'); } }, sortHits : function(value, hits) { var objs = []; var ii; for (ii = 0; ii < hits.length; ii++) { objs.push(this._raw[hits[ii]]); } var default_comparator = function(u, v) { var key_u = u.sort || u.name; var key_v = v.sort || v.name; return key_u.localeCompare(key_v); }; var handler = this.getSortHandler() || function(value, list, cmp) { list.sort(cmp); }; handler(value, objs, default_comparator); hits.splice(0, hits.length); for (ii = 0; ii < objs.length; ii++) { hits.push(objs[ii].id); } }, renderNodes : function(value, hits) { var n = Math.min(this.getMaximumResultCount(), hits.length); var nodes = []; for (var kk = 0; kk < n; kk++) { nodes.push(this.createNode(this._raw[hits[kk]])); } return nodes; }, createNode : function(data) { return JX.$N( 'a', { sigil: 'typeahead-result', href: data.uri, name: data.name, rel: data.id, className: 'jx-result' }, data.display ); }, normalize : function(str) { return this.getNormalizer()(str); }, tokenize : function(str) { str = this.normalize(str); if (!str.length) { return []; } return str.split(/\s/g); }, _defaultTransformer : function(object) { return { name : object[0], display : object[0], uri : object[1], id : object[2] }; } } });