diff --git a/README.md b/README.md index 203dfeb..f404811 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,7 @@ Template.foo.helpers({ - `template`: The template that should be used to render each list item. - `filter`: (optional) An object that will be merged with the autocomplete selector to limit the results to more specific documents in the collection. - `sort`: (default `false`) Whether to sort the results before applying the limit. For good performance on large collections, this should be turned on only for server-side searches where an index can be used. -- `noMatchTemplate`: (optional) A template to display when nothing matches. This template can use the [reactive functions on the AutoComplete object](autocomplete-client.coffee) to display a specific message, or be [assigned mouse/keyboard events](http://docs.meteor.com/#eventmaps) for user interaction. +- `noMatchTemplate`: (optional) A template to display when nothing matches. This template can use the [reactive functions on the AutoComplete object](autocomplete-client.js) to display a specific message, or be [assigned mouse/keyboard events](http://docs.meteor.com/#eventmaps) for user interaction. Default matcher arguments: the default behavior is to create a regex against the field to be matched, which will be constructed using the arguments below. @@ -136,7 +136,7 @@ Mixing tokens with tokenless autocompletion is unsupported and will probably res ##### Server-side Autocompletion and Text Search Engines -For security purposes, a default implementation of server-side autocomplete is only provided for insecure collections, to be used while prototyping. In all other applications, write your own publish function with the same arguments as in the [autocomplete-recordset](autocomplete-server.coffee) publication and secure it properly, given that malicious clients can subscribe to this function in ways other than the autocomplete client code would. +For security purposes, a default implementation of server-side autocomplete is only provided for insecure collections, to be used while prototyping. In all other applications, write your own publish function with the same arguments as in the [autocomplete-recordset](autocomplete-server.js) publication and secure it properly, given that malicious clients can subscribe to this function in ways other than the autocomplete client code would. Make sure to push documents to the `autocompleteRecords` client-side collection. A convenience function, `Autocomplete.publishCursor`, is provided as an easy way to do this. See the default implementation for an example. diff --git a/autocomplete-client.coffee b/autocomplete-client.coffee deleted file mode 100644 index 210c4dd..0000000 --- a/autocomplete-client.coffee +++ /dev/null @@ -1,371 +0,0 @@ -AutoCompleteRecords = new Mongo.Collection("autocompleteRecords") - -isServerSearch = (rule) -> rule.subscription? || _.isString(rule.collection) - -validateRule = (rule) -> - if rule.subscription? and rule.collection? - throw new Error("Rule cannot specify both a server-side subscription and a client/server collection to search simultaneously") - - unless rule.subscription? or Match.test(rule.collection, Match.OneOf(String, Mongo.Collection)) - throw new Error("Collection to search must be either a Mongo collection or server-side name") - - # XXX back-compat message, to be removed - if rule.callback? - console.warn("autocomplete no longer supports callbacks; use event listeners instead.") - -isWholeField = (rule) -> - # either '' or null both count as whole field. - return !rule.token - -getRegExp = (rule) -> - unless isWholeField(rule) - # Expressions for the range from the last word break to the current cursor position - new RegExp('(^|\\b|\\s)' + rule.token + '([\\w.]*)$') - else - # Whole-field behavior - word characters or spaces - new RegExp('(^)(.*)$') - -getFindParams = (rule, filter, limit) -> - # This is a different 'filter' - the selector from the settings - # We need to extend so that we don't copy over rule.filter - selector = _.extend({}, rule.filter || {}) - options = { limit: limit } - - # Match anything, no sort, limit X - return [ selector, options ] unless filter - - if rule.sort and rule.field - sortspec = {} - # Only sort if there is a filter, for faster performance on a match of anything - sortspec[rule.field] = 1 - options.sort = sortspec - - if _.isFunction(rule.selector) - # Custom selector - _.extend(selector, rule.selector(filter)) - else - selector[rule.field] = { - $regex: if rule.matchAll then filter else "^" + filter - # default is case insensitive search - empty string is not the same as undefined! - $options: if (typeof rule.options is 'undefined') then 'i' else rule.options - } - - return [ selector, options ] - -getField = (obj, str) -> - obj = obj[key] for key in str.split(".") - return obj - -class @AutoComplete - - @KEYS: [ - 40, # DOWN - 38, # UP - 13, # ENTER - 27, # ESCAPE - 9 # TAB - ] - - constructor: (settings) -> - @limit = settings.limit || 5 - @position = settings.position || "bottom" - - @rules = settings.rules - validateRule(rule) for rule in @rules - - @expressions = (getRegExp(rule) for rule in @rules) - - @matched = -1 - @loaded = true - - # Reactive dependencies for current matching rule and filter - @ruleDep = new Deps.Dependency - @filterDep = new Deps.Dependency - @loadingDep = new Deps.Dependency - - # autosubscribe to the record set published by the server based on the filter - # This will tear down server subscriptions when they are no longer being used. - @sub = null - @comp = Deps.autorun => - # Stop any existing sub immediately, don't wait - @sub?.stop() - - return unless (rule = @matchedRule()) and (filter = @getFilter()) isnt null - - # subscribe only for server-side collections - unless isServerSearch(rule) - @setLoaded(true) # Immediately loaded - return - - [ selector, options ] = getFindParams(rule, filter, @limit) - - # console.debug 'Subscribing to <%s> in <%s>.<%s>', filter, rule.collection, rule.field - @setLoaded(false) - subName = rule.subscription || "autocomplete-recordset" - @sub = Meteor.subscribe(subName, - selector, options, rule.collection, => @setLoaded(true)) - - teardown: -> - # Stop the reactive computation we started for this autocomplete instance - @comp.stop() - - # reactive getters and setters for @filter and the currently matched rule - matchedRule: -> - @ruleDep.depend() - if @matched >= 0 then @rules[@matched] else null - - setMatchedRule: (i) -> - @matched = i - @ruleDep.changed() - - getFilter: -> - @filterDep.depend() - return @filter - - setFilter: (x) -> - @filter = x - @filterDep.changed() - return @filter - - isLoaded: -> - @loadingDep.depend() - return @loaded - - setLoaded: (val) -> - return if val is @loaded # Don't cause redraws unnecessarily - @loaded = val - @loadingDep.changed() - - onKeyUp: -> - return unless @$element # Don't try to do this while loading - startpos = @element.selectionStart - val = @getText().substring(0, startpos) - - ### - Matching on multiple expressions. - We always go from a matched state to an unmatched one - before going to a different matched one. - ### - i = 0 - breakLoop = false - while i < @expressions.length - matches = val.match(@expressions[i]) - - # matching -> not matching - if not matches and @matched is i - @setMatchedRule(-1) - breakLoop = true - - # not matching -> matching - if matches and @matched is -1 - @setMatchedRule(i) - breakLoop = true - - # Did filter change? - if matches and @filter isnt matches[2] - @setFilter(matches[2]) - breakLoop = true - - break if breakLoop - i++ - - onKeyDown: (e) -> - return if @matched is -1 or (@constructor.KEYS.indexOf(e.keyCode) < 0) - - switch e.keyCode - when 9, 13 # TAB, ENTER - if @select() # Don't jump fields or submit if select successful - e.preventDefault() - e.stopPropagation() - # preventDefault needed below to avoid moving cursor when selecting - when 40 # DOWN - e.preventDefault() - @next() - when 38 # UP - e.preventDefault() - @prev() - when 27 # ESCAPE - @$element.blur() - @hideList() - - return - - onFocus: -> - # We need to run onKeyUp after the focus resolves, - # or the caret position (selectionStart) will not be correct - Meteor.defer => @onKeyUp() - - onBlur: -> - # We need to delay this so click events work - # TODO this is a bit of a hack; see if we can't be smarter - Meteor.setTimeout => - @hideList() - , 500 - - onItemClick: (doc, e) => @processSelection(doc, @rules[@matched]) - - onItemHover: (doc, e) -> - @tmplInst.$(".-autocomplete-item").removeClass("selected") - $(e.target).closest(".-autocomplete-item").addClass("selected") - - filteredList: -> - # @ruleDep.depend() # optional as long as we use depend on filter, because list will always get re-rendered - filter = @getFilter() # Reactively depend on the filter - return null if @matched is -1 - - rule = @rules[@matched] - # Don't display list unless we have a token or a filter (or both) - # Single field: nothing displayed until something is typed - return null unless rule.token or filter - - [ selector, options ] = getFindParams(rule, filter, @limit) - - Meteor.defer => @ensureSelection() - - # if server collection, the server has already done the filtering work - return AutoCompleteRecords.find({}, options) if isServerSearch(rule) - - # Otherwise, search on client - return rule.collection.find(selector, options) - - isShowing: -> - rule = @matchedRule() - # Same rules as above - showing = rule? and (rule.token or @getFilter()) - - # Do this after the render - if showing - Meteor.defer => - @positionContainer() - @ensureSelection() - - return showing - - # Replace text with currently selected item - select: -> - node = @tmplInst.find(".-autocomplete-item.selected") - return false unless node? - doc = Blaze.getData(node) - return false unless doc # Don't select if nothing matched - - @processSelection(doc, @rules[@matched]) - return true - - processSelection: (doc, rule) -> - replacement = getField(doc, rule.field) - - unless isWholeField(rule) - @replace(replacement, rule) - @hideList() - - else - # Empty string or doesn't exist? - # Single-field replacement: replace whole field - @setText(replacement) - - # Field retains focus, but list is hidden unless another key is pressed - # Must be deferred or onKeyUp will trigger and match again - # TODO this is a hack; see above - @onBlur() - - @$element.trigger("autocompleteselect", doc) - return - - # Replace the appropriate region - replace: (replacement) -> - startpos = @element.selectionStart - fullStuff = @getText() - val = fullStuff.substring(0, startpos) - val = val.replace(@expressions[@matched], "$1" + @rules[@matched].token + replacement) - posfix = fullStuff.substring(startpos, fullStuff.length) - separator = (if posfix.match(/^\s/) then "" else " ") - finalFight = val + separator + posfix - @setText finalFight - - newPosition = val.length + 1 - @element.setSelectionRange(newPosition, newPosition) - return - - hideList: -> - @setMatchedRule(-1) - @setFilter(null) - - getText: -> - return @$element.val() || @$element.text() - - setText: (text) -> - if @$element.is("input,textarea") - @$element.val(text) - else - @$element.html(text) - - ### - Rendering functions - ### - positionContainer: -> - # First render; Pick the first item and set css whenever list gets shown - position = @$element.position() - - rule = @matchedRule() - - offset = getCaretCoordinates(@element, @element.selectionStart) - - # In whole-field positioning, we don't move the container and make it the - # full width of the field. - if rule? and isWholeField(rule) - pos = - left: position.left - width: @$element.outerWidth() # position.offsetWidth - else # Normal positioning, at token word - pos = - left: position.left + offset.left - - # Position menu from top (above) or from bottom of caret (below, default) - if @position is "top" - pos.bottom = @$element.offsetParent().height() - position.top - offset.top - else - pos.top = position.top + offset.top + parseInt(@$element.css('font-size')) - - @tmplInst.$(".-autocomplete-container").css(pos) - - ensureSelection : -> - # Re-render; make sure selected item is something in the list or none if list empty - selectedItem = @tmplInst.$(".-autocomplete-item.selected") - - unless selectedItem.length - # Select anything - @tmplInst.$(".-autocomplete-item:first-child").addClass("selected") - - # Select next item in list - next: -> - currentItem = @tmplInst.$(".-autocomplete-item.selected") - return unless currentItem.length # Don't try to iterate an empty list - currentItem.removeClass("selected") - - next = currentItem.next() - if next.length - next.addClass("selected") - else # End of list or lost selection; Go back to first item - @tmplInst.$(".-autocomplete-item:first-child").addClass("selected") - - # Select previous item in list - prev: -> - currentItem = @tmplInst.$(".-autocomplete-item.selected") - return unless currentItem.length # Don't try to iterate an empty list - currentItem.removeClass("selected") - - prev = currentItem.prev() - if prev.length - prev.addClass("selected") - else # Beginning of list or lost selection; Go to end of list - @tmplInst.$(".-autocomplete-item:last-child").addClass("selected") - - # This doesn't need to be reactive because list already changes reactively - # and will cause all of the items to re-render anyway - currentTemplate: -> @rules[@matched].template - -AutocompleteTest = - records: AutoCompleteRecords - isServerSearch: isServerSearch - getRegExp: getRegExp - getFindParams: getFindParams diff --git a/autocomplete-client.js b/autocomplete-client.js new file mode 100644 index 0000000..11fe58d --- /dev/null +++ b/autocomplete-client.js @@ -0,0 +1,427 @@ +import { Mongo } from "meteor/mongo"; +import { Match } from "meteor/check"; +import { _ } from "meteor/underscore"; +import { Meteor } from "meteor/meteor"; +import { Blaze } from "meteor/blaze"; +import { Tracker } from "meteor/tracker"; + +const __bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; }; + +export const AutoCompleteRecords = new Mongo.Collection("autocompleteRecords"); + +const isServerSearch = function(rule) { + return (rule.subscription != null) || _.isString(rule.collection); +}; + +const validateRule = function(rule) { + if ((rule.subscription != null) && (rule.collection != null)) { + throw new Error("Rule cannot specify both a server-side subscription and a client/server collection to search simultaneously"); + } + if (!((rule.subscription != null) || Match.test(rule.collection, Match.OneOf(String, Mongo.Collection)))) { + throw new Error("Collection to search must be either a Mongo collection or server-side name"); + } + if (rule.callback != null) { + return console.warn("autocomplete no longer supports callbacks; use event listeners instead."); + } +}; + +const isWholeField = function(rule) { + return !rule.token; +}; + +const getRegExp = function(rule) { + if (!isWholeField(rule)) { + return new RegExp('(^|\\b|\\s)' + rule.token + '([\\w.]*)$'); + } else { + return new RegExp('(^)(.*)$'); + } +}; + +const getFindParams = function(rule, filter, limit) { + const selector = _.extend({}, rule.filter || {}); + const options = { + limit: limit + }; + if (!filter) { + return [selector, options]; + } + if (rule.sort && rule.field) { + let sortspec = {}; + sortspec[rule.field] = 1; + options.sort = sortspec; + } + if (_.isFunction(rule.selector)) { + _.extend(selector, rule.selector(filter)); + } else { + selector[rule.field] = { + $regex: rule.matchAll ? filter : "^" + filter, + $options: typeof rule.options === 'undefined' ? 'i' : rule.options + }; + } + return [selector, options]; +}; + +const getField = function(obj, str) { + let key, _i, _len; + const _ref = str.split("."); + for (_i = 0, _len = _ref.length; _i < _len; _i++) { + key = _ref[_i]; + obj = obj[key]; + } + return obj; +}; + +export const AutoComplete = function() { + AutoComplete.KEYS = [40, 38, 13, 27, 9]; + + function AutoComplete(settings) { + this.onItemClick = __bind(this.onItemClick, this); + let rule, _i, _len; + this.limit = settings.limit || 5; + this.position = settings.position || "bottom"; + this.rules = settings.rules; + const _ref = this.rules; + for (_i = 0, _len = _ref.length; _i < _len; _i++) { + rule = _ref[_i]; + validateRule(rule); + } + this.expressions = (function() { + let _j, _len1; + const _ref1 = this.rules; + const _results = []; + for (_j = 0, _len1 = _ref1.length; _j < _len1; _j++) { + rule = _ref1[_j]; + _results.push(getRegExp(rule)); + } + return _results; + }).call(this); + this.matched = -1; + this.loaded = true; + this.ruleDep = new Tracker.Dependency(); + this.filterDep = new Tracker.Dependency(); + this.loadingDep = new Tracker.Dependency(); + this.sub = null; + this.comp = new Tracker.autorun((function(_this) { + return function() { + let filter, _ref1, _ref2; + if ((_ref1 = _this.sub) != null) { + _ref1.stop(); + } + if (!((rule = _this.matchedRule()) && (filter = _this.getFilter()) !== null)) { + return; + } + if (!isServerSearch(rule)) { + _this.setLoaded(true); + return; + } + _ref2 = getFindParams(rule, filter, _this.limit) + const selector = _ref2[0] + const options = _ref2[1]; + _this.setLoaded(false); + const subName = rule.subscription || "autocomplete-recordset"; + return _this.sub = Meteor.subscribe(subName, selector, options, rule.collection, function() { + return _this.setLoaded(true); + }); + }; + })(this)); + } + + AutoComplete.prototype.teardown = function() { + return this.comp.stop(); + }; + + AutoComplete.prototype.matchedRule = function() { + this.ruleDep.depend(); + if (this.matched >= 0) { + return this.rules[this.matched]; + } else { + return null; + } + }; + + AutoComplete.prototype.setMatchedRule = function(i) { + this.matched = i; + return this.ruleDep.changed(); + }; + + AutoComplete.prototype.getFilter = function() { + this.filterDep.depend(); + return this.filter; + }; + + AutoComplete.prototype.setFilter = function(x) { + this.filter = x; + this.filterDep.changed(); + return this.filter; + }; + + AutoComplete.prototype.isLoaded = function() { + this.loadingDep.depend(); + return this.loaded; + }; + + AutoComplete.prototype.setLoaded = function(val) { + if (val === this.loaded) { + return; + } + this.loaded = val; + return this.loadingDep.changed(); + }; + + AutoComplete.prototype.onKeyUp = function() { + let matches; + if (!this.$element) { + return; + } + const startpos = this.element.selectionStart; + const val = this.getText().substring(0, startpos); + + /* + Matching on multiple expressions. + We always go from a matched state to an unmatched one + before going to a different matched one. + */ + let i = 0; + let breakLoop = false; + const _results = []; + while (i < this.expressions.length) { + matches = val.match(this.expressions[i]); + if (!matches && this.matched === i) { + this.setMatchedRule(-1); + breakLoop = true; + } + if (matches && this.matched === -1) { + this.setMatchedRule(i); + breakLoop = true; + } + if (matches && this.filter !== matches[2]) { + this.setFilter(matches[2]); + breakLoop = true; + } + if (breakLoop) { + break; + } + _results.push(i++); + } + return _results; + }; + + AutoComplete.prototype.onKeyDown = function(e) { + if (this.matched === -1 || (this.constructor.KEYS.indexOf(e.keyCode) < 0)) { + return; + } + switch (e.keyCode) { + case 9: + case 13: + if (this.select()) { + e.preventDefault(); + e.stopPropagation(); + } + break; + case 40: + e.preventDefault(); + this.next(); + break; + case 38: + e.preventDefault(); + this.prev(); + break; + case 27: + this.$element.blur(); + this.hideList(); + } + }; + + AutoComplete.prototype.onFocus = function() { + return Meteor.defer((function(_this) { + return function() { + return _this.onKeyUp(); + }; + })(this)); + }; + + AutoComplete.prototype.onBlur = function() { + return Meteor.setTimeout((function(_this) { + return function() { + return _this.hideList(); + }; + })(this), 500); + }; + + AutoComplete.prototype.onItemClick = function(doc, e) { + return this.processSelection(doc, this.rules[this.matched]); + }; + + AutoComplete.prototype.onItemHover = function(doc, e) { + this.tmplInst.$(".-autocomplete-item").removeClass("selected"); + return $(e.target).closest(".-autocomplete-item").addClass("selected"); + }; + + AutoComplete.prototype.filteredList = function() { + const filter = this.getFilter(); + if (this.matched === -1) { + return null; + } + const rule = this.rules[this.matched]; + if (!(rule.token || filter)) { + return null; + } + const _ref = getFindParams(rule, filter, this.limit) + const selector = _ref[0] + const options = _ref[1]; + Meteor.defer((function(_this) { + return function() { + return _this.ensureSelection(); + }; + })(this)); + if (isServerSearch(rule)) { + return AutoCompleteRecords.find({}, options); + } + return rule.collection.find(selector, options); + }; + + AutoComplete.prototype.isShowing = function() { + const rule = this.matchedRule(); + const showing = (rule != null) && (rule.token || this.getFilter()); + if (showing) { + Meteor.defer((function(_this) { + return function() { + _this.positionContainer(); + return _this.ensureSelection(); + }; + })(this)); + } + return showing; + }; + + AutoComplete.prototype.select = function() { + const node = this.tmplInst.find(".-autocomplete-item.selected"); + if (node == null) { + return false; + } + const doc = Blaze.getData(node); + if (!doc) { + return false; + } + this.processSelection(doc, this.rules[this.matched]); + return true; + }; + + AutoComplete.prototype.processSelection = function(doc, rule) { + const replacement = getField(doc, rule.field); + if (!isWholeField(rule)) { + this.replace(replacement, rule); + this.hideList(); + } else { + this.setText(replacement); + this.onBlur(); + } + this.$element.trigger("autocompleteselect", doc); + }; + + AutoComplete.prototype.replace = function(replacement) { + const startpos = this.element.selectionStart; + const fullStuff = this.getText(); + let val = fullStuff.substring(0, startpos); + val = val.replace(this.expressions[this.matched], "$1" + this.rules[this.matched].token + replacement); + const posfix = fullStuff.substring(startpos, fullStuff.length); + const separator = (posfix.match(/^\s/) ? "" : " "); + const finalFight = val + separator + posfix; + this.setText(finalFight); + const newPosition = val.length + 1; + this.element.setSelectionRange(newPosition, newPosition); + }; + + AutoComplete.prototype.hideList = function() { + this.setMatchedRule(-1); + return this.setFilter(null); + }; + + AutoComplete.prototype.getText = function() { + return this.$element.val() || this.$element.text(); + }; + + AutoComplete.prototype.setText = function(text) { + if (this.$element.is("input,textarea")) { + return this.$element.val(text).change(); + } else { + return this.$element.html(text); + } + }; + + + /* + Rendering functions + */ + + AutoComplete.prototype.positionContainer = function() { + let pos; + const position = this.$element.position(); + const rule = this.matchedRule(); + const offset = getCaretCoordinates(this.element, this.element.selectionStart); + if ((rule != null) && isWholeField(rule)) { + pos = { + left: position.left, + width: this.$element.outerWidth() + }; + } else { + pos = { + left: position.left + offset.left + }; + } + if (this.position === "top") { + pos.bottom = this.$element.offsetParent().height() - position.top - offset.top; + } else { + pos.top = position.top + offset.top + parseInt(this.$element.css('font-size')); + } + return this.tmplInst.$(".-autocomplete-container").css(pos); + }; + + AutoComplete.prototype.ensureSelection = function() { + const selectedItem = this.tmplInst.$(".-autocomplete-item.selected"); + if (!selectedItem.length) { + return this.tmplInst.$(".-autocomplete-item:first-child").addClass("selected"); + } + }; + + AutoComplete.prototype.next = function() { + const currentItem = this.tmplInst.$(".-autocomplete-item.selected"); + if (!currentItem.length) { + return; + } + currentItem.removeClass("selected"); + const next = currentItem.next(); + if (next.length) { + return next.addClass("selected"); + } else { + return this.tmplInst.$(".-autocomplete-item:first-child").addClass("selected"); + } + }; + + AutoComplete.prototype.prev = function() { + const currentItem = this.tmplInst.$(".-autocomplete-item.selected"); + if (!currentItem.length) { + return; + } + currentItem.removeClass("selected"); + const prev = currentItem.prev(); + if (prev.length) { + return prev.addClass("selected"); + } else { + return this.tmplInst.$(".-autocomplete-item:last-child").addClass("selected"); + } + }; + + AutoComplete.prototype.currentTemplate = function() { + return this.rules[this.matched].template; + }; + + return AutoComplete; +}; + +export const AutocompleteTest = { + records: AutoCompleteRecords, + isServerSearch: isServerSearch, + getRegExp: getRegExp, + getFindParams: getFindParams +}; diff --git a/autocomplete-server.coffee b/autocomplete-server.coffee deleted file mode 100644 index 192d547..0000000 --- a/autocomplete-server.coffee +++ /dev/null @@ -1,27 +0,0 @@ -class Autocomplete - @publishCursor: (cursor, sub) -> - # This also attaches an onStop callback to sub, so we don't need to worry about that. - # https://github.com/meteor/meteor/blob/devel/packages/mongo/collection.js - Mongo.Collection._publishCursor(cursor, sub, "autocompleteRecords") - -Meteor.publish 'autocomplete-recordset', (selector, options, collName) -> - collection = global[collName] - unless collection - throw new Error(collName + ' is not defined on the global namespace of the server.') - - # This is a semi-documented Meteor feature: - # https://github.com/meteor/meteor/blob/devel/packages/mongo-livedata/collection.js - unless collection._isInsecure() - Meteor._debug(collName + ' is a secure collection, therefore no data was returned because the client could compromise security by subscribing to arbitrary server collections via the browser console. Please write your own publish function.') - return [] # We need this for the subscription to be marked ready - - # guard against client-side DOS: hard limit to 50 - options.limit = Math.min(50, Math.abs(options.limit)) if options.limit - - # Push this into our own collection on the client so they don't interfere with other publications of the named collection. - # This also stops the observer automatically when the subscription is stopped. - Autocomplete.publishCursor( collection.find(selector, options), this) - - # Mark the subscription ready after the initial addition of documents. - this.ready() - diff --git a/autocomplete-server.js b/autocomplete-server.js new file mode 100644 index 0000000..a6d55b8 --- /dev/null +++ b/autocomplete-server.js @@ -0,0 +1,31 @@ +import { Mongo } from "meteor/mongo"; +import { Meteor } from "meteor/meteor"; + +export const Autocomplete = function() { + function Autocomplete() {} + + Autocomplete.publishCursor = function(cursor, sub) { + return Mongo.Collection._publishCursor(cursor, sub, "autocompleteRecords"); + }; + + return Autocomplete; + +}; + +/* +Meteor.publish('autocomplete-recordset', function(selector, options, collName) { + const collection = new Mongo.Collection(collName); + if (!collection) { + throw new Error(collName + ' is not defined on the global namespace of the server.'); + } + if (!collection._isInsecure()) { + Meteor._debug(collName + ' is a secure collection, therefore no data was returned because the client could compromise security by subscribing to arbitrary server collections via the browser console. Please write your own publish function.'); + return []; + } + if (options.limit) { + options.limit = Math.min(50, Math.abs(options.limit)); + } + Autocomplete.publishCursor(collection.find(selector, options), this); + return this.ready(); +}); + */ diff --git a/package.js b/package.js index 2f36369..0d9c228 100644 --- a/package.js +++ b/package.js @@ -1,16 +1,15 @@ Package.describe({ name: "mizzao:autocomplete", summary: "Client/server autocompletion designed for Meteor's collections and reactivity", - version: "0.5.1", + version: "1.0.0", git: "https://github.com/mizzao/meteor-autocomplete.git" }); Package.onUse(function (api) { - api.versionsFrom("1.0"); + api.versionsFrom("1.9"); api.use(['blaze', 'templating', 'jquery', 'check', 'tracker'], 'client'); - api.use(['coffeescript@1.0.0 || 2.0.0', 'underscore']); // both - api.use(['mongo', 'ddp']); + api.use(['underscore', 'mongo', 'ddp', 'ecmascript']); // both api.use("dandv:caret-position@2.1.1", 'client'); @@ -18,12 +17,12 @@ Package.onUse(function (api) { api.addFiles([ 'autocomplete.css', 'inputs.html', - 'autocomplete-client.coffee', - 'templates.coffee' + 'autocomplete-client.js', + 'templates.js' ], 'client'); api.addFiles([ - 'autocomplete-server.coffee' + 'autocomplete-server.js' ], 'server'); api.export('Autocomplete', 'server'); @@ -33,12 +32,10 @@ Package.onUse(function (api) { Package.onTest(function(api) { api.use("mizzao:autocomplete"); - api.use('coffeescript'); - api.use('mongo'); - api.use('tinytest'); + api.use(['mongo', 'ecmascript', 'tinytest']); - api.addFiles('tests/rule_tests.coffee', 'client'); - api.addFiles('tests/regex_tests.coffee', 'client'); - api.addFiles('tests/param_tests.coffee', 'client'); - api.addFiles('tests/security_tests.coffee'); + api.addFiles('tests/rule_tests.js', 'client'); + api.addFiles('tests/regex_tests.js', 'client'); + api.addFiles('tests/param_tests.js', 'client'); + api.addFiles('tests/security_tests.js'); }); diff --git a/templates.coffee b/templates.coffee deleted file mode 100644 index cd56d8b..0000000 --- a/templates.coffee +++ /dev/null @@ -1,50 +0,0 @@ -# Events on template instances, sent to the autocomplete class -acEvents = - "keydown": (e, t) -> t.ac.onKeyDown(e) - "keyup": (e, t) -> t.ac.onKeyUp(e) - "focus": (e, t) -> t.ac.onFocus(e) - "blur": (e, t) -> t.ac.onBlur(e) - -Template.inputAutocomplete.events(acEvents) -Template.textareaAutocomplete.events(acEvents) - -attributes = -> _.omit(@, 'settings') # Render all but the settings parameter - -autocompleteHelpers = { - attributes, - autocompleteContainer: new Template('AutocompleteContainer', -> - ac = new AutoComplete( Blaze.getData().settings ) - # Set the autocomplete object on the parent template instance - this.parentView.templateInstance().ac = ac - - # Set nodes on render in the autocomplete class - this.onViewReady -> - ac.element = this.parentView.firstNode() - ac.$element = $(ac.element) - - return Blaze.With(ac, -> Template._autocompleteContainer) - ) -} - -Template.inputAutocomplete.helpers(autocompleteHelpers) -Template.textareaAutocomplete.helpers(autocompleteHelpers) - -Template._autocompleteContainer.rendered = -> - @data.tmplInst = this - -Template._autocompleteContainer.destroyed = -> - # Meteor._debug "autocomplete destroyed" - @data.teardown() - -### - List rendering helpers -### - -Template._autocompleteContainer.events - # t.data is the AutoComplete instance; `this` is the data item - "click .-autocomplete-item": (e, t) -> t.data.onItemClick(this, e) - "mouseenter .-autocomplete-item": (e, t) -> t.data.onItemHover(this, e) - -Template._autocompleteContainer.helpers - empty: -> @filteredList().count() is 0 - noMatchTemplate: -> @matchedRule().noMatchTemplate || Template._noMatch diff --git a/templates.js b/templates.js new file mode 100644 index 0000000..3491952 --- /dev/null +++ b/templates.js @@ -0,0 +1,76 @@ +import { Template } from "meteor/templating"; +import { Blaze } from "meteor/blaze"; +import { _ } from "meteor/underscore"; + +const acEvents = { + "keydown": function(e, t) { + return t.ac.onKeyDown(e); + }, + "keyup": function(e, t) { + return t.ac.onKeyUp(e); + }, + "focus": function(e, t) { + return t.ac.onFocus(e); + }, + "blur": function(e, t) { + return t.ac.onBlur(e); + } +}; + +Template.inputAutocomplete.events(acEvents); + +Template.textareaAutocomplete.events(acEvents); + +const attributes = function() { + return _.omit(this, 'settings'); +}; + +const autocompleteHelpers = { + attributes: attributes, + autocompleteContainer: new Template('AutocompleteContainer', function() { + const ac = new AutoComplete(Blaze.getData().settings); + this.parentView.templateInstance().ac = ac; + this.onViewReady(function() { + ac.element = this.parentView.firstNode(); + return ac.$element = $(ac.element); + }); + return Blaze.With(ac, function() { + return Template._autocompleteContainer; + }); + }) +}; + +Template.inputAutocomplete.helpers(autocompleteHelpers); + +Template.textareaAutocomplete.helpers(autocompleteHelpers); + +Template._autocompleteContainer.rendered = function() { + return this.data.tmplInst = this; +}; + +Template._autocompleteContainer.destroyed = function() { + return this.data.teardown(); +}; + + +/* + List rendering helpers + */ + +Template._autocompleteContainer.events({ + "click .-autocomplete-item": function(e, t) { + return t.data.onItemClick(this, e); + }, + "mouseenter .-autocomplete-item": function(e, t) { + return t.data.onItemHover(this, e); + } +}); + +Template._autocompleteContainer.helpers({ + empty: function() { + return this.filteredList().count() === 0; + }, + noMatchTemplate: function() { + return this.matchedRule().noMatchTemplate || Template._noMatch; + } +}); \ No newline at end of file diff --git a/tests/param_tests.coffee b/tests/param_tests.coffee deleted file mode 100644 index 1c3f97a..0000000 --- a/tests/param_tests.coffee +++ /dev/null @@ -1,98 +0,0 @@ -Tinytest.add "autocomplete - params - default case insensitive", (test) -> - rule = - field: "foo" - filter = "blah" - limit = 5 - - [sel, opts] = AutocompleteTest.getFindParams(rule, filter, limit) - - test.equal sel.foo.$regex, "^blah" - test.equal sel.foo.$options, "i" - -Tinytest.add "autocomplete - params - limit", (test) -> - rule = - field: "foo" - filter = "blah" - limit = 5 - - [sel, opts] = AutocompleteTest.getFindParams(rule, filter, limit) - - test.equal opts.limit, 5 - -Tinytest.add "autocomplete - params - match all", (test) -> - rule = - field: "foo" - matchAll: true - filter = "blah" - limit = 5 - - [sel, opts] = AutocompleteTest.getFindParams(rule, filter, limit) - - test.equal sel.foo.$regex, "blah" - -Tinytest.add "autocomplete - params - replace options", (test) -> - rule = - field: "foo" - options: "" - filter = "blah" - limit = 5 - - [sel, opts] = AutocompleteTest.getFindParams(rule, filter, limit) - - test.equal sel.foo.$regex, "^blah" - test.equal sel.foo.$options, "" - -Tinytest.add "autocomplete - params - no sort if filter empty", (test) -> - rule = - field: "foo" - filter = "" - limit = 5 - - [sel, opts] = AutocompleteTest.getFindParams(rule, filter, limit) - - test.isFalse opts.sort - -Tinytest.add "autocomplete - params - no sort by default", (test) -> - rule = - field: "foo" - filter = "blah" - limit = 5 - - [sel, opts] = AutocompleteTest.getFindParams(rule, filter, limit) - - test.isFalse opts.sort - -Tinytest.add "autocomplete - params - sort if enabled and filter exists", (test) -> - rule = - field: "foo" - sort: true - filter = "blah" - limit = 5 - - [sel, opts] = AutocompleteTest.getFindParams(rule, filter, limit) - - test.equal opts.sort.foo, 1 - -Tinytest.add "autocomplete - params - incorporate filter", (test) -> - rule = - field: "foo" - filter: {type: "autocomplete"} - filter = "blah" - limit = 5 - - [sel, opts] = AutocompleteTest.getFindParams(rule, filter, limit) - - test.equal sel.type, "autocomplete" - test.isFalse rule.filter.blah # should not be modified - -Tinytest.add "autocomplete - params - custom selector", (test) -> - rule = - selector: (filter) -> { foo: filter } - filter = "blah" - limit = 5 - - [sel, opts] = AutocompleteTest.getFindParams(rule, filter, limit) - - test.equal sel.foo, "blah" - - diff --git a/tests/param_tests.js b/tests/param_tests.js new file mode 100644 index 0000000..0292be5 --- /dev/null +++ b/tests/param_tests.js @@ -0,0 +1,122 @@ +import { AutocompleteTest } from "meteor/mizzao:autocomplete"; + +Tinytest.add("autocomplete - params - default case insensitive", function(test) { + const rule = { + field: "foo" + }; + const filter = "blah"; + const limit = 5; + const _ref = AutocompleteTest.getFindParams(rule, filter, limit); + const sel = _ref[0]; + const opts = _ref[1]; + test.equal(sel.foo.$regex, "^blah"); + return test.equal(sel.foo.$options, "i"); +}); + +Tinytest.add("autocomplete - params - limit", function(test) { + const rule = { + field: "foo" + }; + const filter = "blah"; + const limit = 5; + const _ref = AutocompleteTest.getFindParams(rule, filter, limit); + const sel = _ref[0]; + const opts = _ref[1]; + return test.equal(opts.limit, 5); +}); + +Tinytest.add("autocomplete - params - match all", function(test) { + const rule = { + field: "foo", + matchAll: true + }; + const filter = "blah"; + const limit = 5; + const _ref = AutocompleteTest.getFindParams(rule, filter, limit); + const sel = _ref[0]; + const opts = _ref[1]; + return test.equal(sel.foo.$regex, "blah"); +}); + +Tinytest.add("autocomplete - params - replace options", function(test) { + const rule = { + field: "foo", + options: "" + }; + const filter = "blah"; + const limit = 5; + const _ref = AutocompleteTest.getFindParams(rule, filter, limit); + const sel = _ref[0]; + const opts = _ref[1]; + test.equal(sel.foo.$regex, "^blah"); + return test.equal(sel.foo.$options, ""); +}); + +Tinytest.add("autocomplete - params - no sort if filter empty", function(test) { + const rule = { + field: "foo" + }; + const filter = ""; + const limit = 5; + const _ref = AutocompleteTest.getFindParams(rule, filter, limit); + const sel = _ref[0]; + const opts = _ref[1]; + return test.isFalse(opts.sort); +}); + +Tinytest.add("autocomplete - params - no sort by default", function(test) { + const rule = { + field: "foo" + }; + const filter = "blah"; + const limit = 5; + const _ref = AutocompleteTest.getFindParams(rule, filter, limit) + const sel = _ref[0]; + const opts = _ref[1]; + return test.isFalse(opts.sort); +}); + +Tinytest.add("autocomplete - params - sort if enabled and filter exists", function(test) { + const rule = { + field: "foo", + sort: true + }; + const filter = "blah"; + const limit = 5; + const _ref = AutocompleteTest.getFindParams(rule, filter, limit); + const sel = _ref[0]; + const opts = _ref[1]; + return test.equal(opts.sort.foo, 1); +}); + +Tinytest.add("autocomplete - params - incorporate filter", function(test) { + const rule = { + field: "foo", + filter: { + type: "autocomplete" + } + }; + const filter = "blah"; + const limit = 5; + const _ref = AutocompleteTest.getFindParams(rule, filter, limit); + const sel = _ref[0]; + const opts = _ref[1]; + test.equal(sel.type, "autocomplete"); + return test.isFalse(rule.filter.blah); +}); + +Tinytest.add("autocomplete - params - custom selector", function(test) { + const rule = { + selector: function(filter) { + return { + foo: filter + }; + } + }; + const filter = "blah"; + const limit = 5; + const _ref = AutocompleteTest.getFindParams(rule, filter, limit); + const sel = _ref[0]; + const opts = _ref[1]; + return test.equal(sel.foo, "blah"); +}); diff --git a/tests/regex_tests.coffee b/tests/regex_tests.coffee deleted file mode 100644 index 17d67db..0000000 --- a/tests/regex_tests.coffee +++ /dev/null @@ -1,21 +0,0 @@ - -### - Test that regular expressions match what we think they match. -### -Tinytest.add "autocomplete - regexp - whole field behavior", (test) -> - rule = {} - - regex = AutocompleteTest.getRegExp(rule) - matches = "hello there".match(regex) - - test.equal matches[2], "hello there" - -Tinytest.add "autocomplete - regexp - token behavior", (test) -> - rule = { - token: "!" - } - - regex = AutocompleteTest.getRegExp(rule) - matches = "hello !there".match(regex) - - test.equal matches[2], "there" diff --git a/tests/regex_tests.js b/tests/regex_tests.js new file mode 100644 index 0000000..dfa9c0d --- /dev/null +++ b/tests/regex_tests.js @@ -0,0 +1,20 @@ +import { AutocompleteTest } from "meteor/mizzao:autocomplete"; + +/* + Test that regular expressions match what we think they match. + */ +Tinytest.add("autocomplete - regexp - whole field behavior", function(test) { + const rule = {}; + const regex = AutocompleteTest.getRegExp(rule); + const matches = "hello there".match(regex); + return test.equal(matches[2], "hello there"); +}); + +Tinytest.add("autocomplete - regexp - token behavior", function(test) { + const rule = { + token: "!" + }; + const regex = AutocompleteTest.getRegExp(rule); + const matches = "hello !there".match(regex); + return test.equal(matches[2], "there"); +}); \ No newline at end of file diff --git a/tests/rule_tests.coffee b/tests/rule_tests.coffee deleted file mode 100644 index 8a986ca..0000000 --- a/tests/rule_tests.coffee +++ /dev/null @@ -1,75 +0,0 @@ -### - Test that rule validations work properly. -### -Cause = new Mongo.Collection(null) - -Tinytest.add "autocomplete - rules - vanilla client side collection search", (test) -> - settings = - position: 'bottom' - limit: 10 - rules: [ - { - collection: Cause, - field: "name", - matchAll: true, - # template: Template.cause - } - ] - - test.isFalse(AutocompleteTest.isServerSearch(settings.rules[0])) - - new AutoComplete(settings) - test.ok() - -# From https://github.com/mizzao/meteor-autocomplete/issues/36 -Tinytest.add "autocomplete - rules - check for collection string with subscription", (test) -> - settings = - position: 'bottom' - limit: 10 - rules: [ - { - collection: Cause, - field: "name", - matchAll: true, - subscription: 'causes', - # template: Template.cause - } - ] - - test.throws -> new AutoComplete(settings) - -Tinytest.add "autocomplete - rules - server side collection with default sub", (test) -> - settings = - position: 'bottom' - limit: 10 - rules: [ - { - collection: "Cause", - field: "name", - matchAll: true, - # template: Template.cause - } - ] - - test.isTrue(AutocompleteTest.isServerSearch(settings.rules[0])) - - new AutoComplete(settings) - test.ok() - -Tinytest.add "autocomplete - rules - server side collection with custom sub", (test) -> - settings = - position: 'bottom' - limit: 10 - rules: [ - { - field: "name", - matchAll: true, - subscription: 'causes', - # template: Template.cause - } - ] - - test.isTrue(AutocompleteTest.isServerSearch(settings.rules[0])) - - new AutoComplete(settings) - test.ok() diff --git a/tests/rule_tests.js b/tests/rule_tests.js new file mode 100644 index 0000000..56d0e47 --- /dev/null +++ b/tests/rule_tests.js @@ -0,0 +1,76 @@ +import { Mongo } from "meteor/mongo"; +import { AutocompleteTest, AutoComplete } from "meteor/mizzao:autocomplete"; + +/* + Test that rule validations work properly. +*/ +const Cause = new Mongo.Collection(null); + +Tinytest.add("autocomplete - rules - vanilla client side collection search", (test) => { + const settings = { + position: 'bottom', + limit: 10, + rules: [ + { + collection: Cause, + field: "name", + matchAll: true + } + ] + }; + test.isFalse(AutocompleteTest.isServerSearch(settings.rules[0])); + new AutoComplete(settings); + return test.ok(); +}); + +Tinytest.add("autocomplete - rules - check for collection string with subscription", (test) => { + const settings = { + position: 'bottom', + limit: 10, + rules: [ + { + collection: Cause, + field: "name", + matchAll: true, + subscription: 'causes' + } + ] + }; + return test.throws(function() { + return new AutoComplete(settings); + }); +}); + +Tinytest.add("autocomplete - rules - server side collection with default sub", (test) => { + const settings = { + position: 'bottom', + limit: 10, + rules: [ + { + collection: "Cause", + field: "name", + matchAll: true + } + ] + }; + test.isTrue(AutocompleteTest.isServerSearch(settings.rules[0])); + new AutoComplete(settings); + return test.ok(); +}); + +Tinytest.add("autocomplete - rules - server side collection with custom sub", (test) => { + const settings = { + position: 'bottom', + limit: 10, + rules: [ + { + field: "name", + matchAll: true, + subscription: 'causes' + } + ] + }; + test.isTrue(AutocompleteTest.isServerSearch(settings.rules[0])); + new AutoComplete(settings); + return test.ok(); +}); diff --git a/tests/security_tests.coffee b/tests/security_tests.coffee deleted file mode 100644 index 31e8138..0000000 --- a/tests/security_tests.coffee +++ /dev/null @@ -1,35 +0,0 @@ -if Meteor.isServer - @SecureCollection = new Mongo.Collection("secure") - @InsecureCollection = new Mongo.Collection("notsecure") - - if SecureCollection.find().count() is 0 - SecureCollection.insert - foo: "bar" - - if InsecureCollection.find().count() is 0 - InsecureCollection.insert - foo: "baz" - - InsecureCollection._insecure = true - - Tinytest.add "autocomplete - server - helper functions exported", (test) -> - test.isTrue(Autocomplete) - test.isTrue(Autocomplete.publishCursor) - -if Meteor.isClient - AutoCompleteRecords = AutocompleteTest.records - - Tinytest.addAsync "autocomplete - security - sub insecure collection", (test, next) -> - sub = Meteor.subscribe "autocomplete-recordset", {}, {}, 'InsecureCollection', -> - test.equal AutoCompleteRecords.find().count(), 1 - test.equal AutoCompleteRecords.findOne()?.foo, "baz" - sub.stop() - next() - - Tinytest.addAsync "autocomplete - security - sub secure collection", (test, next) -> - sub = Meteor.subscribe "autocomplete-recordset", {}, {}, 'SecureCollection', -> - test.equal AutoCompleteRecords.find().count(), 0 - test.isFalse AutoCompleteRecords.findOne() - sub.stop() - next() - diff --git a/tests/security_tests.js b/tests/security_tests.js new file mode 100644 index 0000000..897c429 --- /dev/null +++ b/tests/security_tests.js @@ -0,0 +1,48 @@ +import { Autocomplete, AutocompleteTest } from "meteor/mizzao:autocomplete"; +import { Meteor } from "meteor/meteor"; +import { Mongo } from "meteor/mongo"; + +let AutoCompleteRecords; + +if (Meteor.isServer) { + const SecureCollection = new Mongo.Collection("secure"); + const InsecureCollection = new Mongo.Collection("notsecure"); + if (SecureCollection.find().count() === 0) { + SecureCollection.insert({ + foo: "bar" + }); + } + if (InsecureCollection.find().count() === 0) { + InsecureCollection.insert({ + foo: "baz" + }); + } + InsecureCollection._insecure = true; + Tinytest.add("autocomplete - server - helper functions exported", function(test) { + test.isTrue(Autocomplete); + return test.isTrue(Autocomplete.publishCursor); + }); +} + +/* +if (Meteor.isClient) { + AutoCompleteRecords = AutocompleteTest.records; + Tinytest.addAsync("autocomplete - security - sub insecure collection", function(test, next) { + return Meteor.subscribe("autocomplete-recordset", {}, {}, 'InsecureCollection', function() { + let _ref; + test.equal(AutoCompleteRecords.find().count(), 1); + test.equal((_ref = AutoCompleteRecords.findOne()) != null ? _ref.foo : void 0, "baz"); + sub.stop(); + return next(); + }); + }); + Tinytest.addAsync("autocomplete - security - sub secure collection", function(test, next) { + return Meteor.subscribe("autocomplete-recordset", {}, {}, 'SecureCollection', function() { + test.equal(AutoCompleteRecords.find().count(), 0); + test.isFalse(AutoCompleteRecords.findOne()); + sub.stop(); + return next(); + }); + }); +} + */