[aur-dev] [PATCH] Implemented typeahead suggest
--- web/html/home.php | 19 ++- web/html/index.php | 4 + web/html/js/bootstrap-typeahead.js | 311 +++++++++++++++++++++++++++++++++++++ web/lib/aurjson.class.php | 22 ++- 4 files changed, 354 insertions(+), 2 deletions(-) create mode 100644 web/html/js/bootstrap-typeahead.js diff --git a/web/html/home.php b/web/html/home.php index 4e489ba..0b51d55 100644 --- a/web/html/home.php +++ b/web/html/home.php @@ -95,7 +95,7 @@ $dbh = db_connect(); <fieldset> <label for="pkgsearch-field"><?= __('Package Search') ?>:</label> <input type="hidden" name="O" value="0" /> - <input type="text" name="K" size="30" value="<?php if (isset($_REQUEST["K"])) { print stripslashes(trim(htmlspecialchars($_REQUEST["K"], ENT_QUOTES))); } ?>" maxlength="35" /> + <input id="pkgsearch-field" type="text" name="K" size="30" value="<?php if (isset($_REQUEST["K"])) { print stripslashes(trim(htmlspecialchars($_REQUEST["K"], ENT_QUOTES))); } ?>" maxlength="35" /> </fieldset> </form> </div> @@ -107,5 +107,22 @@ $dbh = db_connect(); </div> </div> +<script type="text/javascript" src="https://ajax.googleapis.com/ajax/libs/jquery/1.8.2/jquery.min.js"></script> +<script type="text/javascript" src="/js/bootstrap-typeahead.js"></script> +<script type="text/javascript"> +$(document).ready(function() { + $('#pkgsearch-field').typeahead({ + source: function(query, callback) { + $.getJSON('<?= get_uri('/rpc'); ?>', {type: "suggest", arg: query}, function(data) { + callback(data); + }); + }, + matcher: function(item) { return true; }, + sorter: function(items) { return items; }, + menu: '<ul class="pkgsearch-typeahead"></ul>', + items: 20 + }).attr('autocomplete', 'off'); +}); +</script> <?php html_footer(AUR_VERSION); diff --git a/web/html/index.php b/web/html/index.php index a197d0b..c51f409 100644 --- a/web/html/index.php +++ b/web/html/index.php @@ -118,6 +118,10 @@ if (!empty($tokens[1]) && '/' . $tokens[1] == get_pkg_route()) { header("Content-Type: image/png"); include "./$path"; break; + case "/js/bootstrap-typeahead.js": + header("Content-Type: application/javascript"); + include "./$path"; + break; default: header("HTTP/1.0 404 Not Found"); include "./404.php"; diff --git a/web/html/js/bootstrap-typeahead.js b/web/html/js/bootstrap-typeahead.js new file mode 100644 index 0000000..2bb0355 --- /dev/null +++ b/web/html/js/bootstrap-typeahead.js @@ -0,0 +1,311 @@ +/* ============================================================= + * bootstrap-typeahead.js v2.2.1 + * http://twitter.github.com/bootstrap/javascript.html#typeahead + * ============================================================= + * Copyright 2012 Twitter, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ============================================================ */ + + +!function($){ + + "use strict"; // jshint ;_; + + + /* TYPEAHEAD PUBLIC CLASS DEFINITION + * ================================= */ + + var Typeahead = function (element, options) { + this.$element = $(element) + this.options = $.extend({}, $.fn.typeahead.defaults, options) + this.matcher = this.options.matcher || this.matcher + this.sorter = this.options.sorter || this.sorter + this.highlighter = this.options.highlighter || this.highlighter + this.updater = this.options.updater || this.updater + this.$menu = $(this.options.menu).appendTo('body') + this.source = this.options.source + this.shown = false + this.listen() + } + + Typeahead.prototype = { + + constructor: Typeahead + + , select: function () { + var val = this.$menu.find('.active').attr('data-value') + this.$element + .val(this.updater(val)) + .change() + return this.hide() + } + + , updater: function (item) { + return item + } + + , show: function () { + var pos = $.extend({}, this.$element.offset(), { + height: this.$element[0].offsetHeight + }) + + this.$menu.css({ + top: pos.top + pos.height + , left: pos.left + }) + + this.$menu.show() + this.shown = true + return this + } + + , hide: function () { + this.$menu.hide() + this.shown = false + return this + } + + , lookup: function (event) { + var items + + this.query = this.$element.val() + + if (!this.query || this.query.length < this.options.minLength) { + return this.shown ? this.hide() : this + } + + items = $.isFunction(this.source) ? this.source(this.query, $.proxy(this.process, this)) : this.source + + return items ? this.process(items) : this + } + + , process: function (items) { + var that = this + + items = $.grep(items, function (item) { + return that.matcher(item) + }) + + items = this.sorter(items) + + if (!items.length) { + return this.shown ? this.hide() : this + } + + return this.render(items.slice(0, this.options.items)).show() + } + + , matcher: function (item) { + return ~item.toLowerCase().indexOf(this.query.toLowerCase()) + } + + , sorter: function (items) { + var beginswith = [] + , caseSensitive = [] + , caseInsensitive = [] + , item + + while (item = items.shift()) { + if (!item.toLowerCase().indexOf(this.query.toLowerCase())) beginswith.push(item) + else if (~item.indexOf(this.query)) caseSensitive.push(item) + else caseInsensitive.push(item) + } + + return beginswith.concat(caseSensitive, caseInsensitive) + } + + , highlighter: function (item) { + var query = this.query.replace(/[\-\[\]{}()*+?.,\\\^$|#\s]/g, '\\$&') + return item.replace(new RegExp('(' + query + ')', 'ig'), function ($1, match) { + return '<strong>' + match + '</strong>' + }) + } + + , render: function (items) { + var that = this + + items = $(items).map(function (i, item) { + i = $(that.options.item).attr('data-value', item) + i.find('a').html(that.highlighter(item)) + return i[0] + }) + + items.first().addClass('active') + this.$menu.html(items) + return this + } + + , next: function (event) { + var active = this.$menu.find('.active').removeClass('active') + , next = active.next() + + if (!next.length) { + next = $(this.$menu.find('li')[0]) + } + + next.addClass('active') + } + + , prev: function (event) { + var active = this.$menu.find('.active').removeClass('active') + , prev = active.prev() + + if (!prev.length) { + prev = this.$menu.find('li').last() + } + + prev.addClass('active') + } + + , listen: function () { + this.$element + .on('blur', $.proxy(this.blur, this)) + .on('keypress', $.proxy(this.keypress, this)) + .on('keyup', $.proxy(this.keyup, this)) + + if (this.eventSupported('keydown')) { + this.$element.on('keydown', $.proxy(this.keydown, this)) + } + + this.$menu + .on('click', $.proxy(this.click, this)) + .on('mouseenter', 'li', $.proxy(this.mouseenter, this)) + } + + , eventSupported: function(eventName) { + var isSupported = eventName in this.$element + if (!isSupported) { + this.$element.setAttribute(eventName, 'return;') + isSupported = typeof this.$element[eventName] === 'function' + } + return isSupported + } + + , move: function (e) { + if (!this.shown) return + + switch(e.keyCode) { + case 9: // tab + case 13: // enter + case 27: // escape + e.preventDefault() + break + + case 38: // up arrow + e.preventDefault() + this.prev() + break + + case 40: // down arrow + e.preventDefault() + this.next() + break + } + + e.stopPropagation() + } + + , keydown: function (e) { + this.suppressKeyPressRepeat = !~$.inArray(e.keyCode, [40,38,9,13,27]) + this.move(e) + } + + , keypress: function (e) { + if (this.suppressKeyPressRepeat) return + this.move(e) + } + + , keyup: function (e) { + switch(e.keyCode) { + case 40: // down arrow + case 38: // up arrow + case 16: // shift + case 17: // ctrl + case 18: // alt + break + + case 9: // tab + case 13: // enter + if (!this.shown) return + this.select() + break + + case 27: // escape + if (!this.shown) return + this.hide() + break + + default: + this.lookup() + } + + e.stopPropagation() + e.preventDefault() + } + + , blur: function (e) { + var that = this + setTimeout(function () { that.hide() }, 150) + } + + , click: function (e) { + e.stopPropagation() + e.preventDefault() + this.select() + this.$element.focus() + } + + , mouseenter: function (e) { + this.$menu.find('.active').removeClass('active') + $(e.currentTarget).addClass('active') + } + + } + + + /* TYPEAHEAD PLUGIN DEFINITION + * =========================== */ + + $.fn.typeahead = function (option) { + return this.each(function () { + var $this = $(this) + , data = $this.data('typeahead') + , options = typeof option == 'object' && option + if (!data) $this.data('typeahead', (data = new Typeahead(this, options))) + if (typeof option == 'string') data[option]() + }) + } + + $.fn.typeahead.defaults = { + source: [] + , items: 8 + , menu: '<ul class="typeahead dropdown-menu"></ul>' + , item: '<li><a></a></li>' + , minLength: 1 + } + + $.fn.typeahead.Constructor = Typeahead + + + /* TYPEAHEAD DATA-API + * ================== */ + + $(document).on('focus.typeahead.data-api', '[data-provide="typeahead"]', function (e) { + var $this = $(this) + if ($this.data('typeahead')) return + e.preventDefault() + $this.typeahead($this.data()) + }) + +}(window.jQuery); diff --git a/web/lib/aurjson.class.php b/web/lib/aurjson.class.php index 949c34f..d58cec1 100644 --- a/web/lib/aurjson.class.php +++ b/web/lib/aurjson.class.php @@ -15,7 +15,7 @@ include_once("aur.inc.php"); class AurJSON { private $dbh = false; private static $exposed_methods = array( - 'search', 'info', 'multiinfo', 'msearch' + 'search', 'info', 'multiinfo', 'msearch', 'suggest' ); private static $fields = array( 'Packages.ID', 'Name', 'Version', 'CategoryID', 'Description', 'URL', @@ -276,5 +276,25 @@ class AurJSON { return $this->process_query('msearch', $where_condition); } + + /** + * Get all package names that start with $search. + * @param $search Search string. + * @return string The JSON formatted response data. + **/ + private function suggest($search) { + $query = 'SELECT Name FROM Packages WHERE Name LIKE ' . + $this->dbh->quote(addcslashes($search, '%_') . '%') . + ' ORDER BY Name ASC LIMIT 20'; + + $result = $this->dbh->query($query); + $result_array = array(); + + if ($result) { + $result_array = $result->fetchAll(PDO::FETCH_COLUMN, 0); + } + + return json_encode($result_array); + } } -- 1.8.0.2
On Sun, Dec 23, 2012 at 4:23 PM, Marcel Korpel <marcel.lists@gmail.com> wrote:
--- web/html/home.php | 19 ++- web/html/index.php | 4 + web/html/js/bootstrap-typeahead.js | 311 +++++++++++++++++++++++++++++++++++++ web/lib/aurjson.class.php | 22 ++- 4 files changed, 354 insertions(+), 2 deletions(-) create mode 100644 web/html/js/bootstrap-typeahead.js
I applied this on my working branch with a commit message and a minor change. I noticed you didn't use upstream's vanilla v2.2.1 release and made your own alterations. See below.
diff --git a/web/html/home.php b/web/html/home.php index 4e489ba..0b51d55 100644 --- a/web/html/home.php +++ b/web/html/home.php @@ -95,7 +95,7 @@ $dbh = db_connect(); <fieldset> <label for="pkgsearch-field"><?= __('Package Search') ?>:</label> <input type="hidden" name="O" value="0" /> - <input type="text" name="K" size="30" value="<?php if (isset($_REQUEST["K"])) { print stripslashes(trim(htmlspecialchars($_REQUEST["K"], ENT_QUOTES))); } ?>" maxlength="35" /> + <input id="pkgsearch-field" type="text" name="K" size="30" value="<?php if (isset($_REQUEST["K"])) { print stripslashes(trim(htmlspecialchars($_REQUEST["K"], ENT_QUOTES))); } ?>" maxlength="35" /> </fieldset> </form> </div> @@ -107,5 +107,22 @@ $dbh = db_connect(); </div>
</div> +<script type="text/javascript" src="https://ajax.googleapis.com/ajax/libs/jquery/1.8.2/jquery.min.js"></script> +<script type="text/javascript" src="/js/bootstrap-typeahead.js"></script> +<script type="text/javascript"> +$(document).ready(function() { + $('#pkgsearch-field').typeahead({ + source: function(query, callback) { + $.getJSON('<?= get_uri('/rpc'); ?>', {type: "suggest", arg: query}, function(data) { + callback(data); + }); + }, + matcher: function(item) { return true; }, + sorter: function(items) { return items; }, + menu: '<ul class="pkgsearch-typeahead"></ul>', + items: 20 + }).attr('autocomplete', 'off'); +}); +</script> <?php html_footer(AUR_VERSION); diff --git a/web/html/index.php b/web/html/index.php index a197d0b..c51f409 100644 --- a/web/html/index.php +++ b/web/html/index.php @@ -118,6 +118,10 @@ if (!empty($tokens[1]) && '/' . $tokens[1] == get_pkg_route()) { header("Content-Type: image/png"); include "./$path"; break; + case "/js/bootstrap-typeahead.js": + header("Content-Type: application/javascript"); + include "./$path"; + break; default: header("HTTP/1.0 404 Not Found"); include "./404.php"; diff --git a/web/html/js/bootstrap-typeahead.js b/web/html/js/bootstrap-typeahead.js new file mode 100644 index 0000000..2bb0355 --- /dev/null +++ b/web/html/js/bootstrap-typeahead.js @@ -0,0 +1,311 @@ +/* ============================================================= + * bootstrap-typeahead.js v2.2.1 + * http://twitter.github.com/bootstrap/javascript.html#typeahead + * ============================================================= + * Copyright 2012 Twitter, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ============================================================ */ + + +!function($){ + + "use strict"; // jshint ;_; + + + /* TYPEAHEAD PUBLIC CLASS DEFINITION + * ================================= */ + + var Typeahead = function (element, options) { + this.$element = $(element) + this.options = $.extend({}, $.fn.typeahead.defaults, options) + this.matcher = this.options.matcher || this.matcher + this.sorter = this.options.sorter || this.sorter + this.highlighter = this.options.highlighter || this.highlighter + this.updater = this.options.updater || this.updater + this.$menu = $(this.options.menu).appendTo('body') + this.source = this.options.source + this.shown = false + this.listen() + } + + Typeahead.prototype = { + + constructor: Typeahead + + , select: function () { + var val = this.$menu.find('.active').attr('data-value') + this.$element + .val(this.updater(val)) + .change() + return this.hide() + } + + , updater: function (item) { + return item + } + + , show: function () { + var pos = $.extend({}, this.$element.offset(), { + height: this.$element[0].offsetHeight + }) + + this.$menu.css({ + top: pos.top + pos.height + , left: pos.left + }) + + this.$menu.show() + this.shown = true + return this + } + + , hide: function () { + this.$menu.hide() + this.shown = false + return this + } + + , lookup: function (event) { + var items + + this.query = this.$element.val() + + if (!this.query || this.query.length < this.options.minLength) { + return this.shown ? this.hide() : this + } + + items = $.isFunction(this.source) ? this.source(this.query, $.proxy(this.process, this)) : this.source + + return items ? this.process(items) : this + } + + , process: function (items) { + var that = this + + items = $.grep(items, function (item) { + return that.matcher(item) + }) + + items = this.sorter(items) + + if (!items.length) { + return this.shown ? this.hide() : this + } + + return this.render(items.slice(0, this.options.items)).show() + } + + , matcher: function (item) { + return ~item.toLowerCase().indexOf(this.query.toLowerCase()) + } + + , sorter: function (items) { + var beginswith = [] + , caseSensitive = [] + , caseInsensitive = [] + , item + + while (item = items.shift()) { + if (!item.toLowerCase().indexOf(this.query.toLowerCase())) beginswith.push(item) + else if (~item.indexOf(this.query)) caseSensitive.push(item) + else caseInsensitive.push(item) + } + + return beginswith.concat(caseSensitive, caseInsensitive) + } + + , highlighter: function (item) { + var query = this.query.replace(/[\-\[\]{}()*+?.,\\\^$|#\s]/g, '\\$&') + return item.replace(new RegExp('(' + query + ')', 'ig'), function ($1, match) { + return '<strong>' + match + '</strong>' + }) + } + + , render: function (items) { + var that = this + + items = $(items).map(function (i, item) { + i = $(that.options.item).attr('data-value', item) + i.find('a').html(that.highlighter(item)) + return i[0] + }) + + items.first().addClass('active') + this.$menu.html(items) + return this + } + + , next: function (event) { + var active = this.$menu.find('.active').removeClass('active') + , next = active.next() + + if (!next.length) { + next = $(this.$menu.find('li')[0]) + } + + next.addClass('active') + } + + , prev: function (event) { + var active = this.$menu.find('.active').removeClass('active') + , prev = active.prev() + + if (!prev.length) { + prev = this.$menu.find('li').last() + } + + prev.addClass('active') + } + + , listen: function () { + this.$element + .on('blur', $.proxy(this.blur, this)) + .on('keypress', $.proxy(this.keypress, this)) + .on('keyup', $.proxy(this.keyup, this)) + + if (this.eventSupported('keydown')) { + this.$element.on('keydown', $.proxy(this.keydown, this)) + } + + this.$menu + .on('click', $.proxy(this.click, this)) + .on('mouseenter', 'li', $.proxy(this.mouseenter, this)) + } + + , eventSupported: function(eventName) { + var isSupported = eventName in this.$element + if (!isSupported) { + this.$element.setAttribute(eventName, 'return;') + isSupported = typeof this.$element[eventName] === 'function' + } + return isSupported + } + + , move: function (e) { + if (!this.shown) return + + switch(e.keyCode) { + case 9: // tab + case 13: // enter + case 27: // escape + e.preventDefault() + break + + case 38: // up arrow + e.preventDefault() + this.prev() + break + + case 40: // down arrow + e.preventDefault() + this.next() + break + } + + e.stopPropagation() + } + + , keydown: function (e) { + this.suppressKeyPressRepeat = !~$.inArray(e.keyCode, [40,38,9,13,27]) + this.move(e) + } + + , keypress: function (e) { + if (this.suppressKeyPressRepeat) return + this.move(e) + } + + , keyup: function (e) { + switch(e.keyCode) { + case 40: // down arrow + case 38: // up arrow + case 16: // shift + case 17: // ctrl + case 18: // alt + break + + case 9: // tab + case 13: // enter + if (!this.shown) return + this.select() + break + + case 27: // escape + if (!this.shown) return + this.hide() + break + + default: + this.lookup() + } + + e.stopPropagation() + e.preventDefault() + } + + , blur: function (e) { + var that = this + setTimeout(function () { that.hide() }, 150) + } + + , click: function (e) { + e.stopPropagation() + e.preventDefault() + this.select() + this.$element.focus()
This is not in version 2.2.1 upstream, but I see your code got pulled for their 2.3.0 release so this seems fine.
+ } + + , mouseenter: function (e) { + this.$menu.find('.active').removeClass('active') + $(e.currentTarget).addClass('active') + } + + } + + + /* TYPEAHEAD PLUGIN DEFINITION + * =========================== */ + + $.fn.typeahead = function (option) { + return this.each(function () { + var $this = $(this) + , data = $this.data('typeahead') + , options = typeof option == 'object' && option + if (!data) $this.data('typeahead', (data = new Typeahead(this, options))) + if (typeof option == 'string') data[option]() + }) + } + + $.fn.typeahead.defaults = { + source: [] + , items: 8 + , menu: '<ul class="typeahead dropdown-menu"></ul>' + , item: '<li><a></a></li>'
This change doesn't exist upstream. So I changed it to what is in v2.2.1 for now. Is there anything relevant upstream? This can be a separate small patch in the future if it really needs to be changed.
+ , minLength: 1 + } + + $.fn.typeahead.Constructor = Typeahead + + + /* TYPEAHEAD DATA-API + * ================== */ + + $(document).on('focus.typeahead.data-api', '[data-provide="typeahead"]', function (e) { + var $this = $(this) + if ($this.data('typeahead')) return + e.preventDefault() + $this.typeahead($this.data()) + }) + +}(window.jQuery); diff --git a/web/lib/aurjson.class.php b/web/lib/aurjson.class.php index 949c34f..d58cec1 100644 --- a/web/lib/aurjson.class.php +++ b/web/lib/aurjson.class.php @@ -15,7 +15,7 @@ include_once("aur.inc.php"); class AurJSON { private $dbh = false; private static $exposed_methods = array( - 'search', 'info', 'multiinfo', 'msearch' + 'search', 'info', 'multiinfo', 'msearch', 'suggest' ); private static $fields = array( 'Packages.ID', 'Name', 'Version', 'CategoryID', 'Description', 'URL', @@ -276,5 +276,25 @@ class AurJSON {
return $this->process_query('msearch', $where_condition); } + + /** + * Get all package names that start with $search. + * @param $search Search string. + * @return string The JSON formatted response data. + **/ + private function suggest($search) { + $query = 'SELECT Name FROM Packages WHERE Name LIKE ' . + $this->dbh->quote(addcslashes($search, '%_') . '%') . + ' ORDER BY Name ASC LIMIT 20'; + + $result = $this->dbh->query($query); + $result_array = array(); + + if ($result) { + $result_array = $result->fetchAll(PDO::FETCH_COLUMN, 0); + } + + return json_encode($result_array); + } }
-- 1.8.0.2
On Sat, Jan 5, 2013 at 9:46 PM, canyonknight <canyonknight@gmail.com> wrote:
+ this.$element.focus()
This is not in version 2.2.1 upstream, but I see your code got pulled for their 2.3.0 release so this seems fine.
Good catch. I used my patched version, but only with this change.
+ , item: '<li><a></a></li>'
This change doesn't exist upstream. So I changed it to what is in v2.2.1 for now. Is there anything relevant upstream? This can be a separate small patch in the future if it really needs to be changed.
Whoops, as I said, I used my patched version by accident. Of course you can change this back. I changed this, because menu items are not links whatsoever. With anchor elements, they appear exactly like links, with a statusbar URL that points to # and a pointer cursor. I'll propose a change upstream in the near future. I also proposed a change to archweb.css at https://bugs.archlinux.org/task/32989 Sorry for the inconvenience about the wrong version of bootstrap-typeahead.js. Regards, Marcel
participants (2)
-
canyonknight
-
Marcel Korpel