Bug 1122925 - Fork Review Board's ui.autocomplete.js so that it can account for a colon search prefix. r=? draft
authorMike Conley <mconley@mozilla.com>
Sat, 17 Jan 2015 01:24:20 -0500
changeset 2222 7978e2d018718e56c0dbf678ab3269306830e4c1
parent 2221 2cf80a9660f37e900d57264b0e270d2c7639c783
push idunknown
push userunknown
push dateunknown
bugs1122925
Bug 1122925 - Fork Review Board's ui.autocomplete.js so that it can account for a colon search prefix. r=?
pylib/rbmozui/rbmozui/extension.py
pylib/rbmozui/rbmozui/static/js/commits.js
pylib/rbmozui/rbmozui/static/js/ui.autocomplete.js.patch
pylib/rbmozui/rbmozui/static/js/ui.rbmozuiautocomplete.js
pylib/rbmozui/rbmozui/templates/rbmozui/review-scripts-js.html
--- a/pylib/rbmozui/rbmozui/extension.py
+++ b/pylib/rbmozui/rbmozui/extension.py
@@ -34,16 +34,19 @@ class RBMozUI(Extension):
         'viewdiff': {
             'source_filenames': ['css/viewdiff.less'],
         },
     }
     js_bundles = {
         'commits': {
             'source_filenames': ['js/commits.js'],
         },
+        'rbmozuiautocomplete': {
+            'source_filenames': ['js/ui.rbmozuiautocomplete.js'],
+        },
         'review': {
             'source_filenames': ['js/review.js'],
         }
     }
 
     def initialize(self):
         # Start by hiding the Testing Done field in all review requests, since
         # Mozilla developers will not be using it.
--- a/pylib/rbmozui/rbmozui/static/js/commits.js
+++ b/pylib/rbmozui/rbmozui/static/js/commits.js
@@ -89,17 +89,17 @@
    * The collection of reviewers per Commit.
    */
   var ReviewerList = Backbone.Collection.extend({
     model: Reviewer
   });
 
   var ReviewerListView = Backbone.View.extend({
     render: function() {
-      return this.collection.pluck("username").join(",");
+      return this.collection.pluck("username").join(", ");
     }
   });
 
   /**
    * This represents a single commit, and knows about the review request
    * for that commit.
    */
   var Commit = Backbone.Model.extend({
@@ -262,20 +262,34 @@
             rootEditor.decr("editCount");
           },
           complete: _.bind(function(e, value) {
             // The ReviewRequestEditor is the interface that we use to modify
             // a review request easily.
             var editor = new RB.ReviewRequestEditor({reviewRequest: this.model.reviewRequest});
             var warning = $("#review-request-warning");
 
+            // For Mozilla, we sometimes use colons as a prefix for searching for
+            // IRC nicks - that's just a convention that has developed over time.
+            // Since IRC nicks are what MozReview recognizes, we need to be careful
+            // that the user hasn't actually included those colon prefixes, otherwise
+            // MozReview is going to complain that it doesn't recognize the user (since
+            // MozReview's notion of a username doesn't include the colon prefix).
+            var sanitized = value.split(" ").map(function(aName) {
+              var trimmed = aName.trim();
+              if (trimmed.indexOf(":") == 0) {
+                trimmed = trimmed.substring(1);
+              }
+              return trimmed;
+            });
+
             // This sets the reviewers on the child review request.
             editor.setDraftField(
               "targetPeople",
-              value,
+              sanitized.join(", "),
               {
                 jsonFieldName: "target_people",
                 error: function(error) {
                   rootEditor.decr("editCount");
                   console.error(error.errorText);
 
                   // This error display code is copied pretty much verbatim
                   // from Review Board core to match the behaviour of attempting
@@ -339,28 +353,29 @@
             return aFullname.localeCompare(bFullname);
           }
         }
       };
 
       // Again, this is copied almost verbatim from Review Board core to
       // mimic traditional behaviour for this kind of field.
       $(reviewerList).inlineEditor("field")
-                     .rbautocomplete({
+                     .rbmozuiautocomplete({
         formatItem: function(data) {
           var s = data[acOptions.nameKey];
           if (acOptions.descKey && data[acOptions.descKey]) {
             s += ' <span>(' + _.escape(data[acOptions.descKey]) +
                  ')</span>';
           }
 
           return s;
         },
         matchCase: false,
         multiple: true,
+        searchPrefix: ":",
         parse: function(data) {
           var items = data[acOptions.fieldName],
               itemsLen = items.length,
               parsed = [],
               value,
               i;
 
           for (i = 0; i < itemsLen; i++) {
new file mode 100644
--- /dev/null
+++ b/pylib/rbmozui/rbmozui/static/js/ui.autocomplete.js.patch
@@ -0,0 +1,47 @@
+diff --git a/pylib/rbmozui/rbmozui/static/js/ui.rbmozuiautocomplete.js b/pylib/rbmozui/rbmozui/static/js/ui.rbmozuiautocomplete.js
+--- a/pylib/rbmozui/rbmozui/static/js/ui.rbmozuiautocomplete.js
++++ b/pylib/rbmozui/rbmozui/static/js/ui.rbmozuiautocomplete.js
+@@ -36,17 +36,18 @@
+         multiple: false,
+         multipleSeparator: ", ",
+         highlight: function(value, term) {
+             return value.replace(new RegExp("(?![^&;]+;)(?!<[^<>]*)(" + term.replace(/([\^\$\(\)\[\]\{\}\*\.\+\?\|\\])/gi, "\\$1") + ")(?![^<>]*>)(?![^&;]+;)", "gi"), "<strong>$1</strong>");
+         },
+         scroll: true,
+         clickToURL: false,
+         enterToURL: false,
+-        scrollHeight: 180
++        scrollHeight: 180,
++        searchPrefix: null,
+     },
+ 
+     _init: function() {
+         $.extend(this.options, {
+             delay: this.options.delay != undefined ? this.options.delay : (this.options.url? this.options.ajaxDelay : this.options.localDelay),
+             max: this.options.max != undefined ? this.options.max : (this.options.scroll? this.options.scrollMax : this.options.noScrollMax),
+             highlight: this.options.highlight || function(value) { return value; }, // if highlight is set to false, replace it with a do-nothing function
+             formatMatch: this.options.formatMatch || this.options.formatItem // if the formatMatch option is not specified, then use formatItem for backwards compatibility
+@@ -273,17 +274,22 @@
+                 return [""];
+             }
+             if ( !options.multiple ) {
+                 return [value];
+             }
+             var words = value.split( options.multipleSeparator );
+             var result = [];
+             $.each(words, function(i, value) {
+-                result[i] = $.trim(value);
++                var trimmed = $.trim(value);
++                if (options.searchPrefix &&
++                    trimmed.indexOf(options.searchPrefix) == 0) {
++                    trimmed = trimmed.substring(options.searchPrefix.length);
++                }
++                result[i] = trimmed;
+             });
+             return result;
+         };
+ 
+         function lastWord(value) {
+             var words = trimWords(value);
+             return words[words.length - 1];
+         };
new file mode 100644
--- /dev/null
+++ b/pylib/rbmozui/rbmozui/static/js/ui.rbmozuiautocomplete.js
@@ -0,0 +1,865 @@
+/*
+ * jQuery UI Autocomplete @VERSION
+ *
+ * Copyright (c) 2007, 2008 Dylan Verheul, Dan G. Switzer, Anjesh Tuladhar, Jörn Zaefferer
+ * Dual licensed under the MIT (MIT-LICENSE.txt)
+ * and GPL (GPL-LICENSE.txt) licenses.
+ * 
+ * http://docs.jquery.com/UI/Autocomplete
+ *
+ * Depends:
+ *    ui.core.js
+ */
+(function($) {
+
+$.widget("ui.rbmozuiautocomplete", {
+    options: {
+        inputClass: "ui-autocomplete-input",
+        resultsClass: "ui-autocomplete-results",
+        loadingClass: "ui-autocomplete-loading",
+        minChars: 1,
+        ajaxDelay: 400,
+        localDelay: 10,
+        matchCase: false,
+        matchSubset: true,
+        matchContains: false,
+        cacheLength: 10,
+        scrollMax: 150,
+        noScrollMax: 10,
+        mustMatch: false,
+        extraParams: {},
+        selectFirst: true,
+        formatItem: function(row) { return row[0]; },
+        formatMatch: null,
+        autoFill: false,
+        width: 0,
+        multiple: false,
+        multipleSeparator: ", ",
+        highlight: function(value, term) {
+            return value.replace(new RegExp("(?![^&;]+;)(?!<[^<>]*)(" + term.replace(/([\^\$\(\)\[\]\{\}\*\.\+\?\|\\])/gi, "\\$1") + ")(?![^<>]*>)(?![^&;]+;)", "gi"), "<strong>$1</strong>");
+        },
+        scroll: true,
+        clickToURL: false,
+        enterToURL: false,
+        scrollHeight: 180,
+        searchPrefix: null,
+    },
+
+    _init: function() {
+        $.extend(this.options, {
+            delay: this.options.delay != undefined ? this.options.delay : (this.options.url? this.options.ajaxDelay : this.options.localDelay),
+            max: this.options.max != undefined ? this.options.max : (this.options.scroll? this.options.scrollMax : this.options.noScrollMax),
+            highlight: this.options.highlight || function(value) { return value; }, // if highlight is set to false, replace it with a do-nothing function
+            formatMatch: this.options.formatMatch || this.options.formatItem // if the formatMatch option is not specified, then use formatItem for backwards compatibility
+        });
+
+        var input = this.element[0],
+            options = this.options,
+            // Create $ object for input element
+            $input = $(input).attr("autocomplete", "off").addClass(options.inputClass),
+            KEY = $.ui.keyCode,
+            previousValue = "",
+            cache = $.ui.rbmozuiautocomplete.cache(options),
+            hasFocus = 0,
+            config = {
+                mouseDownOnSelect: false
+            },
+            timeout,
+            blockSubmit,
+            lastKeyPressCode,
+            select = $.ui.rbmozuiautocomplete.select(options, input, selectCurrent, config);
+
+        if(options.result) {
+            $input.bind('result.rbmozuiautocomplete', options.result);
+        }
+
+        // prevent form submit in opera when selecting with return key
+        $.browser.opera && $(input.form).bind("submit.rbmozuiautocomplete", function() {
+            if (blockSubmit) {
+                blockSubmit = false;
+                return false;
+            }
+        });
+
+        // only opera doesn't trigger keydown multiple times while pressed, others don't work with keypress at all
+        $input.bind(($.browser.opera ? "keypress" : "keydown") + ".rbmozuiautocomplete", function(event) {
+            // track last key pressed
+            lastKeyPressCode = event.keyCode;
+            switch(event.keyCode) {
+
+                case KEY.UP:
+                    event.preventDefault();
+                    if ( select.visible() ) {
+                        select.prev();
+                    } else {
+                        onChange(0, true);
+                    }
+                    break;
+
+                case KEY.DOWN:
+                    event.preventDefault();
+                    if ( select.visible() ) {
+                        select.next();
+                    } else {
+                        onChange(0, true);
+                    }
+                    break;
+
+                case KEY.PAGE_UP:
+                    event.preventDefault();
+                    if ( select.visible() ) {
+                        select.pageUp();
+                    } else {
+                        onChange(0, true);
+                    }
+                    break;
+
+                case KEY.PAGE_DOWN:
+                    event.preventDefault();
+                    if ( select.visible() ) {
+                        select.pageDown();
+                    } else {
+                        onChange(0, true);
+                    }
+                    break;
+
+                // matches also semicolon
+                case options.multiple && $.trim(options.multipleSeparator) == "," && KEY.COMMA:
+                case KEY.TAB:
+                case KEY.ENTER:
+                    if (options.enterToURL && select.current()){
+                        select.current().click();
+                    }
+                    if( selectCurrent() ) {
+                        // stop default to prevent a form submit, Opera needs special handling
+                        event.preventDefault();
+                        blockSubmit = true;
+                        return false;
+                    }
+                    break;
+
+                case KEY.ESCAPE:
+                    select.hide();
+                    break;
+
+                default:
+                    clearTimeout(timeout);
+                    timeout = setTimeout(onChange, options.delay);
+                    break;
+            }
+        }).focus(function(){
+            // track whether the field has focus, we shouldn't process any
+            // results if the field no longer has focus
+            hasFocus++;
+        }).blur(function() {
+            hasFocus = 0;
+            if (!config.mouseDownOnSelect) {
+                hideResults();
+            }
+        }).click(function() {
+            // show select when clicking in a focused field
+            if ( hasFocus++ > 1 && !select.visible() ) {
+                onChange(0, true);
+            }
+        }).bind("search", function() {
+            // TODO why not just specifying both arguments?
+            var fn = (arguments.length > 1) ? arguments[1] : null;
+            function findValueCallback(q, data) {
+                var result;
+                if( data && data.length ) {
+                    for (var i=0; i < data.length; i++) {
+                        if( data[i].result.toLowerCase() == q.toLowerCase() ) {
+                            result = data[i];
+                            break;
+                        }
+                    }
+                }
+                if( typeof fn == "function" ) fn(result);
+                else $input.trigger("result", result && [result.data, result.value]);
+            }
+            $.each(trimWords($input.val()), function(i, value) {
+                request(value, findValueCallback, findValueCallback);
+            });
+        }).bind("flushCache", function() {
+            cache.flush();
+        }).bind("setOptions", function() {
+            $.extend(options, arguments[1]);
+            // if we've updated the data, repopulate
+            if ( "data" in arguments[1] )
+                cache.populate();
+        }).bind("unautocomplete", function() {
+            select.unbind();
+            $input.unbind();
+            $(input.form).unbind(".rbmozuiautocomplete");
+        });
+
+
+        // Private methods
+        function selectCurrent() {
+            var selected = select.selected();
+
+            if ( !selected || !matchCurrent(selected) )
+                return false;
+
+            var v = selected.result;
+            previousValue = v;
+
+            if ( options.multiple ) {
+                var words = trimWords($input.val());
+
+                if ( words.length > 1 ) {
+                    v = words.slice(0, words.length - 1).join( options.multipleSeparator ) + options.multipleSeparator + v;
+                }
+                v += options.multipleSeparator;
+            }
+
+            $input.val(v);
+            hideResultsNow();
+            $input.trigger("result", [selected.data, selected.value]);
+            return true;
+        };
+
+        // Check if the current user input matches the currently selected item.
+        function matchCurrent(selected) {
+            var toMatch = [selected.result];
+
+            if (selected.data.display_name)
+                toMatch.push(selected.data.display_name);
+
+            else if (selected.data.fullname)
+                toMatch = toMatch.concat([selected.data.first_name, selected.data.last_name]);
+
+            var currentValue = lastWord($input.val());
+            var matched = false;
+
+            for (i = 0; i < toMatch.length && !matched; i++) {
+                var match = toMatch[i];
+
+                if (!options.matchCase) {
+                    match = match.toLowerCase();
+                    currentValue = currentValue.toLowerCase();
+                }
+
+                if (options.matchContains)
+                    matched = match.indexOf(currentValue) >= 0
+                else // check if prefix
+                    matched = match.indexOf(currentValue) === 0
+            }
+
+            return matched;
+        };
+
+        function onChange(crap, skipPrevCheck) {
+            var currentValue = $input.val();
+
+            if ( !skipPrevCheck && currentValue == previousValue )
+                return;
+
+            previousValue = currentValue;
+
+            currentValue = lastWord(currentValue);
+            if ( currentValue.length >= options.minChars) {
+                $input.addClass(options.loadingClass);
+                if (!options.matchCase)
+                    currentValue = currentValue.toLowerCase();
+                request(currentValue, receiveData, hideResultsNow);
+            } else {
+                stopLoading();
+                select.hide();
+            }
+        };
+
+        function trimWords(value) {
+            if ( !value ) {
+                return [""];
+            }
+            if ( !options.multiple ) {
+                return [value];
+            }
+            var words = value.split( options.multipleSeparator );
+            var result = [];
+            $.each(words, function(i, value) {
+                var trimmed = $.trim(value);
+                if (options.searchPrefix &&
+                    trimmed.indexOf(options.searchPrefix) == 0) {
+                    trimmed = trimmed.substring(options.searchPrefix.length);
+                }
+                result[i] = trimmed;
+            });
+            return result;
+        };
+
+        function lastWord(value) {
+            var words = trimWords(value);
+            return words[words.length - 1];
+        };
+
+        // fills in the input box w/the first match (assumed to be the best match)
+        // q: the term entered
+        // sValue: the first matching result
+        function autoFill(q, sValue){
+            // autofill in the complete box w/the first match as long as the user hasn't entered in more data
+            // if the last user key pressed was backspace, don't autofill
+            if( options.autoFill && (lastWord($input.val()).toLowerCase() == q.toLowerCase()) && lastKeyPressCode != $.ui.keyCode.BACKSPACE ) {
+                // fill in the value (keep the case the user has typed)
+                $input.val($input.val() + sValue.substring(lastWord(previousValue).length));
+                // select the portion of the value not typed by the user (so the next character will erase)
+                $.ui.rbmozuiautocomplete.selection(input, previousValue.length, previousValue.length + sValue.length);
+            }
+        };
+
+        function hideResults() {
+            clearTimeout(timeout);
+            timeout = setTimeout(hideResultsNow, 200);
+        };
+
+        function hideResultsNow() {
+            var wasVisible = select.visible();
+            select.hide();
+            clearTimeout(timeout);
+            stopLoading();
+            if (options.mustMatch) {
+                // call search and run callback
+                $input.rbmozuiautocomplete("search", function (result){
+                        // if no value found, clear the input box
+                        if( !result ) {
+                            if (options.multiple) {
+                                var words = trimWords($input.val()).slice(0, -1);
+                                $input.val( words.join(options.multipleSeparator) + (words.length ? options.multipleSeparator : "") );
+                            }
+                            else
+                                $input.val( "" );
+                        }
+                    }
+                );
+            }
+            if (wasVisible)
+                // position cursor at end of input field
+                $.ui.rbmozuiautocomplete.selection(input, input.value.length, input.value.length);
+        };
+
+        function receiveData(q, data) {
+            if ( data && data.length && hasFocus ) {
+                stopLoading();
+                select.display(data, q);
+                autoFill(q, data[0].value);
+                select.show();
+            } else {
+                hideResultsNow();
+            }
+        };
+
+        function request(term, success, failure) {
+            if (!options.matchCase)
+                term = term.toLowerCase();
+            var data = cache.load(term);
+            // recieve the cached data
+            if (data && data.length) {
+                success(term, data);
+            // if an AJAX url has been supplied, try loading the data now
+
+            } else if( (typeof options.url == "string") && (options.url.length > 0) ){
+
+                var extraParams = {
+                    timestamp: +new Date()
+                };
+                $.each(options.extraParams, function(key, param) {
+                    extraParams[key] = typeof param == "function" ? param(term) : param;
+                });
+
+                $.ajax({
+                    // try to leverage ajaxQueue plugin to abort previous requests
+                    mode: "abort",
+                    // limit abortion to this input
+                    port: "autocomplete" + input.name,
+                    dataType: options.dataType,
+                    url: options.url,
+                    data: $.extend({
+                        q: lastWord(term),
+                        limit: options.max
+                    }, extraParams),
+                    success: function(data) {
+                        var parsed = options.parse && options.parse(data) || parse(data);
+                        cache.add(term, parsed);
+                        success(term, parsed);
+                    },
+                    error: function(xhr, textStatus, errorThrown) {
+                        if (options.error) {
+                            options.error(xhr, textStatus, errorThrown);
+                        }
+                    }
+                });
+            }
+
+            else if (options.source && typeof options.source == 'function') {
+                var resultData = options.source(term);
+                var parsed = (options.parse) ? options.parse(resultData) : resultData;
+
+                cache.add(term, parsed);
+                success(term, parsed);
+            } else {
+                // if we have a failure, we need to empty the list -- this prevents the the [TAB] key from selecting the last successful match
+                select.emptyList();
+                failure(term);
+            }
+        };
+
+        function parse(data) {
+            var parsed = [];
+            var rows = data.split("\n");
+            for (var i=0; i < rows.length; i++) {
+                var row = $.trim(rows[i]);
+                if (row) {
+                    row = row.split("|");
+                    parsed[parsed.length] = {
+                        data: row,
+                        value: row[0],
+                        result: options.formatResult && options.formatResult(row, row[0]) || row[0]
+                    };
+                }
+            }
+            return parsed;
+        };
+
+        function stopLoading() {
+            $input.removeClass(options.loadingClass);
+        };
+
+    },
+    _propagate: function(n, event) {
+        $.ui.plugin.call(this, n, [event, this.ui()]);
+        return this.element.triggerHandler(n == 'autocomplete' ? n : 'autocomplete'+n, [event, this.ui()], this.options[n]);
+    },
+
+    // Public methods
+    ui: function(event) {
+        return {
+            options: this.options,
+            element: this.element
+        };
+    },
+    result: function(handler) {
+        return this.element.bind("result", handler);
+    },
+    search: function(handler) {
+        return this.element.trigger("search", [handler]);
+    },
+    flushCache: function() {
+        return this.element.trigger("flushCache");
+    },
+    setData: function(key, value){
+        return this.element.trigger("setOptions", [{ key: value }]);
+    },
+    destroy: function() {
+        this.element
+            .removeAttr('disabled')
+            .removeClass('ui-autocomplete-input');
+        return this.element.trigger("unautocomplete");
+    },
+    enable: function() {
+        this.element
+            .removeAttr('disabled')
+            .removeClass('ui-autocomplete-disabled');
+        this.disabled = false;
+    },
+    disable: function() {
+        this.element
+            .attr('disabled', true)
+            .addClass('ui-autocomplete-disabled');
+        this.disabled = true;
+    }
+});
+
+$.ui.rbmozuiautocomplete.cache = function(options) {
+
+    var data = {};
+    var length = 0;
+
+    function matchSubset(s, sub) {
+        if (!options.matchCase)
+            s = s.toLowerCase();
+        var i = s.indexOf(sub);
+        if (i == -1) return false;
+        return i == 0 || options.matchContains;
+    };
+
+    function add(q, value) {
+        if (length > options.cacheLength){
+            flush();
+        }
+        if (!data[q]){ 
+            length++;
+        }
+        data[q] = value;
+    }
+
+    function populate(){
+        if( !options.data ) return false;
+        // track the matches
+        var stMatchSets = {},
+            nullData = 0;
+
+        // no url was specified, we need to adjust the cache length to make sure it fits the local data store
+        if( !options.url ) options.cacheLength = 1;
+
+        // track all options for minChars = 0
+        stMatchSets[""] = [];
+
+        // loop through the array and create a lookup structure
+        for ( var i = 0, ol = options.data.length; i < ol; i++ ) {
+            var rawValue = options.data[i];
+            // if rawValue is a string, make an array otherwise just reference the array
+            rawValue = (typeof rawValue == "string") ? [rawValue] : rawValue;
+
+            var value = options.formatMatch(rawValue, i+1, options.data.length);
+            if ( value === false )
+                continue;
+
+            var firstChar = value.charAt(0).toLowerCase();
+            // if no lookup array for this character exists, look it up now
+            if( !stMatchSets[firstChar] )
+                stMatchSets[firstChar] = [];
+
+            // if the match is a string
+            var row = {
+                value: value,
+                data: rawValue,
+                result: options.formatResult && options.formatResult(rawValue) || value
+            };
+
+            // push the current match into the set list
+            stMatchSets[firstChar].push(row);
+
+            // keep track of minChars zero items
+            if ( nullData++ < options.max ) {
+                stMatchSets[""].push(row);
+            }
+        };
+
+        // add the data items to the cache
+        $.each(stMatchSets, function(i, value) {
+            // increase the cache size
+            options.cacheLength++;
+            // add to the cache
+            add(i, value);
+        });
+    }
+
+    // populate any existing data
+    setTimeout(populate, 25);
+
+    function flush(){
+        data = {};
+        length = 0;
+    }
+
+    return {
+        flush: flush,
+        add: add,
+        populate: populate,
+        load: function(q) {
+            if (!options.cacheLength || !length)
+                return null;
+            /* 
+             * if dealing w/local data and matchContains than we must make sure
+             * to loop through all the data collections looking for matches
+             */
+            if( !options.url && options.matchContains ){
+                // track all matches
+                var csub = [];
+                // loop through all the data grids for matches
+                for( var k in data ){
+                    // don't search through the stMatchSets[""] (minChars: 0) cache
+                    // this prevents duplicates
+                    if( k.length > 0 ){
+                        var c = data[k];
+                        $.each(c, function(i, x) {
+                            // if we've got a match, add it to the array
+                            if (matchSubset(x.value, q)) {
+                                csub.push(x);
+                            }
+                        });
+                    }
+                }
+                return csub;
+            } else 
+            // if the exact item exists, use it
+            if (data[q]){
+                return data[q];
+            } else
+            if (options.matchSubset) {
+                for (var i = q.length - 1; i >= options.minChars; i--) {
+                    var c = data[q.substr(0, i)];
+                    if (c) {
+                        var csub = [];
+                        $.each(c, function(i, x) {
+                            if (matchSubset(x.value, q)) {
+                                csub[csub.length] = x;
+                            }
+                        });
+                        return csub;
+                    }
+                }
+            }
+            return null;
+        }
+    };
+};
+
+$.ui.rbmozuiautocomplete.select = function (options, input, select, config) {
+    var CLASSES = {
+        ACTIVE: "ui-autocomplete-over"
+    };
+
+    var listItems,
+        active = -1,
+        data,
+        term = "",
+        needsInit = true,
+        element,
+        list;
+
+    // Create results
+    function init() {
+        if (!needsInit)
+            return;
+        element = $("<div/>")
+        .hide()
+        .addClass(options.resultsClass)
+        .css("position", "absolute")
+        .appendTo(document.body);
+
+        list = $("<ul/>").appendTo(element).mouseover( function(event) {
+            if(target(event).nodeName && target(event).nodeName.toUpperCase() == 'LI') {
+                active = $("li", list).removeClass(CLASSES.ACTIVE).index(target(event));
+                $(target(event)).addClass(CLASSES.ACTIVE);
+            }
+        }).click(function(event) {
+            $(target(event)).addClass(CLASSES.ACTIVE);
+            select();
+            // TODO provide option to avoid setting focus again after selection? useful for cleanup-on-focus
+            input.focus();
+            return false;
+        }).mousedown(function() {
+            config.mouseDownOnSelect = true;
+        }).mouseup(function() {
+            config.mouseDownOnSelect = false;
+        });
+
+        if( options.width > 0 )
+            element.css("width", options.width);
+
+        needsInit = false;
+    } 
+
+    function target(event) {
+        var element = event.target;
+        while(element && element.tagName != "LI")
+            element = element.parentNode;
+        // more fun with IE, sometimes event.target is empty, just ignore it then
+        if(!element)
+            return [];
+        return element;
+    }
+
+    function moveSelect(step) {
+        listItems.slice(active, active + 1).removeClass(CLASSES.ACTIVE);
+        movePosition(step);
+        var activeItem = listItems.slice(active, active + 1).addClass(CLASSES.ACTIVE);
+        if(options.scroll) {
+            var offset = 0;
+            listItems.slice(0, active).each(function() {
+                offset += this.offsetHeight;
+            });
+            if((offset + activeItem[0].offsetHeight - list.scrollTop()) > list[0].clientHeight) {
+                list.scrollTop(offset + activeItem[0].offsetHeight - list.innerHeight());
+            } else if(offset < list.scrollTop()) {
+                list.scrollTop(offset);
+            }
+        }
+    };
+
+    function movePosition(step) {
+        active += step;
+        if (active < 0) {
+            active = listItems.size() - 1;
+        } else if (active >= listItems.size()) {
+            active = 0;
+        }
+    }
+
+    function limitNumberOfItems(available) {
+        return options.max && options.max < available
+            ? options.max
+            : available;
+    }
+
+    function makeItem(data) {
+        if (options.clickToURL === false) {
+            return $("<li/>");
+        } else {
+            // For Quick Search
+            return $("<li/>").click(function() {
+                window.open(data["url"]);
+            });
+        }
+    }
+
+    function fillList() {
+        if (options.cmp !== undefined) {
+            data.sort(function(a, b) {
+                return options.cmp(term, a, b);
+            });
+        }
+
+        list.empty();
+        var max = limitNumberOfItems(data.length);
+        for (var i=0; i < max; i++) {
+            if (!data[i])
+                continue;
+            var formatted = options.formatItem(data[i].data, i+1, max, data[i].value, term);
+            if ( formatted === false )
+                continue;
+            var li = makeItem(data[i].data)
+                .html(options.highlight(formatted, term))
+                .addClass(i%2 == 0 ? "ui-autocomplete-even" : "ui-autocomplete-odd")
+                .appendTo(list)[0];
+            $.data(li, "ui-autocomplete-data", data[i]);
+        }
+        listItems = list.find("li");
+        if ( options.selectFirst ) {
+            listItems.slice(0, 1).addClass(CLASSES.ACTIVE);
+            active = 0;
+        }
+        // apply bgiframe if available
+        if ( $.fn.bgiframe )
+            list.bgiframe();
+    }
+
+    return {
+        display: function(d, q) {
+            init();
+            data = d;
+            term = q;
+            fillList();
+        },
+        next: function() {
+            moveSelect(1);
+        },
+        prev: function() {
+            moveSelect(-1);
+        },
+        pageUp: function() {
+            if (active != 0 && active - 8 < 0) {
+                moveSelect( -active );
+            } else {
+                moveSelect(-8);
+            }
+        },
+        pageDown: function() {
+            if (active != listItems.size() - 1 && active + 8 > listItems.size()) {
+                moveSelect( listItems.size() - 1 - active );
+            } else {
+                moveSelect(8);
+            }
+        },
+        hide: function() {
+            element && element.hide();
+            listItems && listItems.removeClass(CLASSES.ACTIVE)
+            active = -1;
+            $(input).triggerHandler("autocompletehide", [{}, { options: options }], options["hide"]);
+        },
+        visible : function() {
+            return element && element.is(":visible");
+        },
+        current: function() {
+            return this.visible() && (listItems.filter("." + CLASSES.ACTIVE)[0] || options.selectFirst && listItems[0]);
+        },
+        show: function() {
+            var $input = $(input),
+                $window = $(window),
+                $document = $(document),
+                offset = $input.offset(),
+                inputWidth = input.offsetWidth,
+                inputHeight = input.offsetHeight,
+                windowRight,
+                windowBottom,
+                width,
+                height;
+
+            if(options.scroll) {
+                list.scrollTop(0);
+                list.css({
+                    maxHeight: options.scrollHeight,
+                    overflow: 'auto'
+                });
+
+                if($.browser.msie && typeof document.body.style.maxHeight === "undefined") {
+                    var listHeight = 0;
+                    listItems.each(function() {
+                        listHeight += this.offsetHeight;
+                    });
+                    var scrollbarsVisible = listHeight > options.scrollHeight;
+                    list.css('height', scrollbarsVisible ? options.scrollHeight : listHeight );
+                    if (!scrollbarsVisible) {
+                        // IE doesn't recalculate width when scrollbar disappears
+                        listItems.width( list.width() - parseInt(listItems.css("padding-left")) - parseInt(listItems.css("padding-right")) );
+                    }
+                }
+
+            }
+
+            $(input).triggerHandler("autocompleteshow", [{}, { options: options }], options["show"]);
+
+            width = (typeof options.width === "string" || options.width > 0
+                     ? options.width
+                     : inputWidth);
+            height = element.outerHeight(true);
+
+            windowBottom = $window.height() + $document.scrollTop();
+            windowRight = $window.width() + $document.scrollLeft();
+
+            element
+                .css({
+                    width: width,
+                    top: (offset.top + inputHeight + height > windowBottom
+                          ? offset.top - height
+                          : offset.top + inputHeight),
+                    left: (offset.left + width > windowRight
+                           ? offset.left + inputWidth - width
+                           : offset.left)
+                })
+                .show();
+        },
+        selected: function() {
+            var selected = listItems && listItems.filter("." + CLASSES.ACTIVE).removeClass(CLASSES.ACTIVE);
+            return selected && selected.length && $.data(selected[0], "ui-autocomplete-data");
+        },
+        emptyList: function (){
+            list && list.empty();
+        },
+        unbind: function() {
+            element && element.remove();
+        }
+    };
+};
+
+$.ui.rbmozuiautocomplete.selection = function(field, start, end) {
+    if( field.createTextRange ){
+        var selRange = field.createTextRange();
+        selRange.collapse(true);
+        selRange.moveStart("character", start);
+        selRange.moveEnd("character", end);
+        selRange.select();
+    } else if( field.setSelectionRange ){
+        field.setSelectionRange(start, end);
+    } else {
+        if( field.selectionStart ){
+            field.selectionStart = start;
+            field.selectionEnd = end;
+        }
+    }
+    field.focus();
+};
+
+})(jQuery);
--- a/pylib/rbmozui/rbmozui/templates/rbmozui/review-scripts-js.html
+++ b/pylib/rbmozui/rbmozui/templates/rbmozui/review-scripts-js.html
@@ -1,6 +1,7 @@
 {% load djblets_extensions %}
 {% load rbmozui %}
 
 {% if review_request|isPush %}
+{%   ext_js_bundle extension "rbmozuiautocomplete" %}
 {%   ext_js_bundle extension "review" %}
 {% endif %}