implement search suggestions (bug 546826)

This commit is contained in:
Chris Van 2011-10-03 01:07:30 -07:00
Родитель 6238485813
Коммит f647f49f5d
17 изменённых файлов: 753 добавлений и 4 удалений

Просмотреть файл

@ -6,5 +6,7 @@ from . import views
urlpatterns = patterns('',
url('^$', views.search, name='search.search'),
url('^ajax$', views.ajax_search, name='search.ajax'),
url('^suggestions$', views.ajax_search_suggestions,
name='search.suggestions'),
url('^es$', views.es_search, name='search.es_search'),
)

Просмотреть файл

@ -16,7 +16,7 @@ import bandwagon.views
import browse.views
from addons.models import Addon, Category
from amo.decorators import json_view
from amo.helpers import urlparams
from amo.helpers import locale_url, urlparams
from amo.urlresolvers import reverse
from amo.utils import MenuItem, sorted_groupby
from versions.compare import dict_from_int, version_int
@ -318,6 +318,12 @@ class BaseAjaxSearch(object):
return results
class SuggestionsAjax(BaseAjaxSearch):
# No personas. No webapps.
types = [amo.ADDON_ANY, amo.ADDON_EXTENSION, amo.ADDON_THEME,
amo.ADDON_DICT, amo.ADDON_SEARCH, amo.ADDON_LPAPP]
@json_view
def ajax_search(request):
"""This is currently used only to return add-ons for populating a
@ -328,6 +334,48 @@ def ajax_search(request):
return BaseAjaxSearch(request).items
@json_view
def ajax_search_suggestions(request):
# TODO(cvan): Tests will come when I know this is what fligtar wants.
results = []
q = request.GET.get('q')
if q and (q.isdigit() or (not q.isdigit() and len(q) > 2)):
q_ = q.lower()
# Applications.
for a in amo.APP_USAGE:
if q_ in unicode(a.pretty).lower():
results.append({
'id': a.id,
'label': _(u'{0} Add-ons').format(a.pretty),
'url': locale_url(a.short),
'cls': 'app ' + a.short
})
# Categories.
cats = (Category.objects
.filter(Q(application=request.APP.id) |
Q(type=amo.ADDON_SEARCH))
.exclude(type=amo.ADDON_WEBAPP))
for c in cats:
if not c.name:
continue
name_ = unicode(c.name).lower()
word_matches = [w for w in q_.split() if name_ in w]
if q_ in name_ or word_matches:
results.append({
'id': c.id,
'label': unicode(c.name),
'url': c.get_url_path(),
'cls': 'cat'
})
# Add-ons.
results += SuggestionsAjax(request).items
return results
def name_only_query(q):
return dict(name__text={'query': q, 'boost': 3, 'analyzer': 'standard'},
name__fuzzy={'value': q, 'boost': 2, 'prefix_length': 4},

Просмотреть файл

@ -41,6 +41,18 @@
transition: @property @duration;
}
.transition-property(@property) {
-moz-transition-property: @property;
-webkit-transition-property: @property;
transition-property: @property;
}
.transition-duration(@duration:2s) {
-moz-transition-duration: @duration;
-webkit-transition-duration: @duration;
transition-duration: @duration;
}
.background-size(@size) {
-moz-background-size: @size;
-wekbkit-background-size: @size;

Просмотреть файл

@ -0,0 +1,91 @@
@import 'lib';
#site-search-suggestions {
.transition-property(~'bottom, opacity, visibility');
.transition-duration(.3s);
font-size: 14px;
height: 0;
opacity: 0;
position: absolute;
right: -10px;
bottom: -5px;
left: -10px;
visibility: hidden;
z-index: 1000;
&.visible {
opacity: 1;
bottom: -15px;
visibility: visible;
}
.wrap {
background: #fff;
border: 1px solid #98B2C9;
.border-radius(4px);
.box-shadow(0 0 4px rgba(0, 0, 0, 0.2),
0 -2px 0 rgba(152, 178, 201, 0.3) inset);
position: absolute;
top: 0;
right: 0;
left: 0;
&:before {
background: url(../../img/impala/search-stem.png) 50% 100% no-repeat;
content: "";
display: block;
height: 20px;
position: absolute;
top: -20px;
left: 14px;
width: 40px;
}
}
ul {
margin: 0 16px 10px;
}
p {
margin: 10px 16px 0;
}
a {
background: no-repeat 3px 3px;
.background-size(20px auto);
display: block;
font: 13px/26px @sans-stack;
overflow: hidden;
padding: 0 4px 0 32px;
text-decoration: none;
text-overflow: ellipsis;
white-space: nowrap;
&.cat {
background-image: url(../../img/icons/search-cat.png);
}
&.app {
background-image: url(../../img/app-icons/16/sprite.png);
.background-size(18px auto);
background-position: 4px 5px;
min-height: 0 !important;
}
&.thunderbird {
background-position: 5px -32px;
}
&.seamonkey {
background-position: 4px -68px;
}
&.sunbird {
background-position: 4px -103px;
}
}
a:hover,
a.sel,
&.sel a.sel:hover {
background-color: #EBF4FE;
color: #447BC4;
}
&.sel a:hover {
background-color: transparent;
color: @link;
}
}
.html-rtl #site-search-suggestions .wrap:before {
left: auto;
right: 14px;
}

Двоичные данные
media/img/icons/search-cat.png Normal file

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 632 B

Двоичные данные
media/img/impala/search-stem.png Normal file

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 429 B

Просмотреть файл

@ -0,0 +1,87 @@
function objEqual(a, b) {
return JSON.stringify(a) == JSON.stringify(b);
}
z._AjaxCache = {};
z.AjaxCache = (function() {
return function(namespace) {
if (z._AjaxCache[namespace] === undefined) {
z._AjaxCache[namespace] = {
'previous': {'args': '', 'data': ''},
'items': {}
};
}
return z._AjaxCache[namespace];
};
})();
(function($) {
$.ajaxCache = function(o) {
o = $.extend({
url: '',
type: 'get',
data: {}, // Key/value pairs of form data.
newItems: $.noop, // Callback upon success of items fetched.
cacheSuccess: $.noop, // Callback upon success of items fetched
// in cache.
ajaxSuccess: $.noop, // Callback upon success of Ajax request.
ajaxFailure: $.noop, // Callback upon failure of Ajax request.
}, o);
var cache = z.AjaxCache(o.url + ':' + o.type),
args = JSON.stringify(o.data),
$self = this,
items,
request;
if (args != JSON.stringify(cache.previous.args)) {
if (!!cache.items[args]) {
items = cache.items[args];
if (o.newItems) {
o.newItems(null, items);
}
if (o.cacheSuccess) {
o.cacheSuccess(null, items);
}
} else {
// Make a request to fetch new items.
request = $.ajax({url: o.url, type: o.method, data: o.data});
request.done(function(data) {
var items;
if (!objEqual(data, cache.previous.data)) {
items = data;
}
if (o.newItems) {
o.newItems(data, items);
}
if (o.ajaxSuccess) {
o.ajaxSuccess(data, items);
}
});
// Optional failure callback.
if (o.failure) {
request.fail(function(data) {
o.ajaxFailure(data);
});
}
request.always(function(data) {
// Store items returned from this request.
cache.items[args] = data;
// Store current list of items and form data (arguments).
cache.previous.data = data;
cache.previous.args = args;
});
}
}
return request;
};
})(jQuery);

Просмотреть файл

@ -0,0 +1,184 @@
$(document).ready(function() {
if (waffle.switch('search-suggestions')) {
$('#search #search-q').searchSuggestions($('#site-search-suggestions'));
}
});
$.fn.searchSuggestions = function(results) {
var $self = this,
$form = $self.closest('form'),
$results = results;
// Some base elements that we don't want to keep creating on the fly.
$results.html(
'<div class="wrap"><p><a class="sel" href="#"></a></p><ul></ul></div>'
);
// Control keys that shouldn't trigger new requests.
var ignoreKeys = [
$.ui.keyCode.SHIFT, $.ui.keyCode.CONTROL, $.ui.keyCode.ALT,
19, // pause
$.ui.keyCode.CAPS_LOCK, $.ui.keyCode.ESCAPE,
$.ui.keyCode.PAGE_UP, $.ui.keyCode.PAGE_DOWN,
$.ui.keyCode.LEFT, $.ui.keyCode.UP,
$.ui.keyCode.RIGHT, $.ui.keyCode.DOWN,
$.ui.keyCode.HOME, $.ui.keyCode.END,
$.ui.keyCode.COMMAND,
92, // right windows key
$.ui.keyCode.COMMAND_RIGHT,
219, // left windows key (Opera)
220, // right windows key (Opera)
224 // apple key
];
function pageUp() {
// Select the first element.
$results.find('.sel').removeClass('sel');
$results.removeClass('sel');
$results.find('a:first').addClass('sel');
}
function pageDown() {
// Select the last element.
$results.find('.sel').removeClass('sel');
$results.removeClass('sel');
$results.find('a:last').addClass('sel');
}
function dismissHandler() {
$results.removeClass('visible sel');
$results.find('.sel').removeClass('sel');
}
function rowHandler(e) {
if (e.which == $.ui.keyCode.UP || e.which == $.ui.keyCode.DOWN) {
e.preventDefault();
var $sel = $results.find('.sel'),
$elems = $results.find('a'),
i = $elems.index($sel.get(0));
if ($sel.length && i >= 0) {
if (e.which == $.ui.keyCode.UP) {
// Clamp the value so it goes to the previous row
// but never goes beyond the first row.
i = Math.max(0, i - 1);
} else {
// Clamp the value so it goes to the next row
// but never goes beyond the last row.
i = Math.min(i + 1, $elems.length - 1);
}
} else {
i = 0;
}
$sel.removeClass('sel');
$elems.eq(i).addClass('sel');
$results.addClass('sel').trigger('selectedRowUpdate', [i]);
} else if (e.which == $.ui.keyCode.PAGE_UP ||
e.which == $.ui.keyCode.HOME) {
e.preventDefault();
pageUp();
$results.addClass('sel').trigger('selectedRowUpdate', [0]);
} else if (e.which == $.ui.keyCode.PAGE_DOWN ||
e.which == $.ui.keyCode.END) {
e.preventDefault();
pageDown();
$results.addClass('sel').trigger('selectedRowUpdate',
[$results.find('a').length - 1]);
}
}
function inputHandler(e) {
var val = $self.val();
if (val.length < 3) {
$results.filter('.visible').removeClass('visible');
return;
}
if ($.inArray(e.which, ignoreKeys) !== -1) {
$results.trigger('inputIgnored');
} else {
var msg = format(gettext('Search add-ons for <b>"{0}"</b>'), val);
$results.find('p a').html(msg);
var li_item = template(
'<li><a href="{url}" {icon} {cls}>{name}</a></li>'
);
$.ajaxCache({
url: $results.attr('data-src'),
data: $form.serialize(),
newItems: function(formdata, items) {
var eventName;
if (items !== undefined) {
var ul = '';
$.each(items, function(i, item) {
var d = {url: item.url || '#', icon: '', cls: ''};
if (item.icon) {
d.icon = format(
'style="background-image:url({0})"',
item.icon);
}
if (item.cls) {
d.cls = format('class="{0}"', item.cls);
}
if (item.name) {
d.name = item.name;
ul += li_item(d);
}
});
$results.find('ul').html(ul);
}
highlight(val);
$results.trigger('resultsUpdated', [items]);
}
});
}
if (e.which == $.ui.keyCode.ESCAPE) {
dismissHandler();
}
}
$self.blur(dismissHandler)
.keydown(rowHandler)
.bind('keyup input paste', _.throttle(inputHandler, 250));
$results.delegate('li', 'hover', function() {
$results.find('.sel').removeClass('sel');
$results.addClass('sel');
$(this).find('a').addClass('sel');
}).delegate('p a', 'click', _pd(function() {
$form.submit();
}));
$form.submit(function(e) {
var $sel = $results.find('.sel');
if ($sel.length && $sel.eq(0).attr('href') != '#') {
e.stopPropagation();
e.preventDefault();
window.location = $sel.get(0).href;
}
});
$(window).keyup(function(e) {
if (e.which == 16 || e.which == 83) {
$self.focus();
}
});
function highlight(val) {
// If an item starts with `val`, wrap the matched text with boldness.
var pat = new RegExp('\\b' + val, 'gi');
$results.find('ul a').each(function() {
var $this = $(this),
txt = $this.text(),
matchedTxt = txt.replace(pat, '<b>$&</b>');
if (txt != matchedTxt) {
$this.html(matchedTxt);
}
});
$results.addClass('visible');
}
return this;
};

Просмотреть файл

@ -149,3 +149,19 @@ tests.lacksClass = function($sel, cls) {
equals($sel.hasClass(cls), false,
'Should not have ' + cls + ', got: ' + $sel.attr('class'));
};
tests.equalObjects = function(a, b) {
/*
Asserts that two objects are equal by comparing serialized strings.
deepEqual is stupid and flaky.
*/
equal(JSON.stringify(a), JSON.stringify(b));
}
tests.notEqualObjects = function(a, b) {
/*
Asserts that two objects are unequal by comparing serialized strings.
notDeepEqual is stupid and flaky.
*/
notEqual(JSON.stringify(a), JSON.stringify(b));
}

Просмотреть файл

@ -0,0 +1,156 @@
module('Ajax Cache', {
setup: function() {
this.newItems = {'ajax': [], 'cache': []};
z._AjaxCache = {};
$.mockjaxClear();
$.mockjaxSettings = {
status: 200,
responseTime: 0,
contentType: 'text/json',
dataType: 'json'
};
},
teardown: function() {
$.mockjaxClear();
},
query: function(term, url) {
var self = this,
results = [];
if (url) {
for (var i = 0; i < 10; i++) {
results.push({'id': i, 'url': url});
}
} else {
url = '/cacheMoney';
results = [
{'id': 1, 'url': 'gkoberger.net'},
{'id': 2, 'url': 'gkoberger.net'},
{'id': 3, 'url': 'gkoberger.net'}
];
}
$.mockjax({
url: url,
responseText: JSON.stringify(results),
status: 200,
responseTime: 0
});
var self = this;
this.ajaxCalled = false;
this.cacheCalled = false;
return {
url: url,
data: {'q': term},
ajaxSuccess: function(data, items) {
self.newItems['ajax'].push(items);
self.ajaxCalled = true;
},
cacheSuccess: function(data, items) {
self.newItems['cache'].push(items);
self.cacheCalled = true;
}
};
},
is_ajax: function() {
equal(this.ajaxCalled, true);
equal(this.cacheCalled, false);
},
is_cache: function() {
equal(this.ajaxCalled, false);
equal(this.cacheCalled, true);
}
});
asyncTest('New request', function() {
var self = this;
$.ajaxCache(self.query('some term')).done(function() {
self.is_ajax();
start();
});
});
asyncTest('Identical requests', function() {
var self = this,
request1,
request2;
request1 = $.ajaxCache(self.query('some term')).done(function() {
self.is_ajax();
// This request should be cached.
request2 = $.ajaxCache(self.query('some term'));
self.is_cache();
// Ensure that we returned the correct items.
tests.equalObjects(self.newItems['ajax'], self.newItems['cache']);
// When the request is cached, we don't return an $.ajax request.
equal(request2, undefined);
start();
});
});
asyncTest('Same URLs, unique parameters, same results', function() {
var self = this,
request1,
request2,
request3;
request1 = $.ajaxCache(self.query('some term')).done(function() {
// This is a cached request.
request2 = $.ajaxCache(self.query('some term'));
// This is a request with new parameters but will return same items.
request3 = $.ajaxCache(self.query('new term')).done(function() {
self.is_ajax();
// We return `undefined` when items remain unchanged.
equal(self.newItems['ajax'][1], undefined);
start();
});
});
});
asyncTest('Unique URLs, same parameters, unique results', function() {
var self = this,
request1,
request2;
request1 = $.ajaxCache(self.query('some term', 'poop')).done(function() {
self.is_ajax();
// This is a new request with a different URL.
request2 = $.ajaxCache(self.query('some term', 'crap')).done(function() {
self.is_ajax();
tests.notEqualObjects(self.newItems['ajax'][0],
self.newItems['ajax'][1]);
start();
});
});
});
asyncTest('Unique URLs, unique parameters, unique results', function() {
var self = this,
request1,
request2;
request1 = $.ajaxCache(self.query('some term', 'poop')).done(function() {
self.is_ajax();
// This is a new request with a different URL and different parameters.
request2 = $.ajaxCache(self.query('diff term', 'crap')).done(function() {
self.is_ajax();
tests.notEqualObjects(self.newItems['ajax'][0],
self.newItems['ajax'][1]);
start();
});
});
});

Просмотреть файл

@ -0,0 +1,133 @@
module('Search Suggestions', {
setup: function() {
this.sandbox = tests.createSandbox('#search-suggestions');
this.results = $('#site-search-suggestions', this.sandbox);
this.input = $('#search #search-q', this.sandbox);
this.input.searchSuggestions(this.results);
this.url = this.results.attr('data-src');
this.newItems = {'ajax': [], 'cache': []};
z._AjaxCache = {};
$.mockjaxClear();
$.mockjaxSettings = {
status: 200,
responseTime: 0,
contentType: 'text/json',
dataType: 'json'
};
},
teardown: function() {
this.sandbox.remove();
$.mockjaxClear();
},
mockRequest: function() {
this.jsonResults = [];
for (var i = 0; i < 10; i++) {
this.jsonResults.push({'id': i, 'url': 'dekKobergerStudios.biz'});
}
$.mockjax({
url: this.url,
responseText: JSON.stringify(this.jsonResults),
status: 200,
responseTime: 0
});
},
testInputEvent: function(eventType, fail) {
var self = this,
$input = self.input,
$results = self.results,
query = 'xxx';
self.mockRequest();
if (fail) {
var inputIgnored = false;
// If we send press a bad key, this will check that we ignored it.
self.sandbox.bind('inputIgnored', function(e) {
inputIgnored = true;
});
tests.waitFor(function() {
return inputIgnored;
}).thenDo(function() {
ok(inputIgnored);
start();
});
} else {
self.sandbox.bind('resultsUpdated', function(e, items) {
tests.equalObjects(items, self.jsonResults);
equal($results.find('.wrap p a.sel b').text(),
'"' + query + '"');
start();
});
}
$input.val(query);
$input.triggerHandler(eventType);
}
});
test('Generated HTML tags', function() {
var $results = this.results,
$sel = $results.find('.wrap p a.sel');
equal($sel.length, 1);
equal($sel.find('b').length, 0);
equal($results.find('.wrap ul').length, 1);
});
asyncTest('Results upon good keyup', function() {
this.testInputEvent({type: 'keyup', which: 'x'.charCodeAt(0)});
});
asyncTest('Results upon bad keyup', function() {
this.testInputEvent({type: 'keyup', which: 16}, true);
});
asyncTest('Results upon input', function() {
this.testInputEvent('input');
});
asyncTest('Results upon paste', function() {
this.testInputEvent('paste');
});
asyncTest('Hide results upon escape/blur', function() {
var self = this,
$input = self.input,
$results = self.results;
$input.val('xxx');
$input.triggerHandler('blur');
tests.lacksClass($results, 'visible');
start();
});
asyncTest('Cached results do not change', function() {
var self = this,
$input = self.input,
$results = self.results,
query = 'xxx';
self.mockRequest();
self.sandbox.bind('resultsUpdated', function(e, items) {
equal($results.find('.wrap p a.sel b').text(), '"' + query + '"');
tests.equalObjects(items, self.jsonResults);
if (z._AjaxCache === undefined) {
$input.triggerHandler('paste');
} else {
tests.waitFor(function() {
return z._AjaxCache;
}).thenDo(function() {
var cache = z.AjaxCache(self.url + ':get'),
args = JSON.stringify(self.sandbox.find('form').serialize());
tests.equalObjects(cache.items[args], items);
tests.equalObjects(cache.previous.data, items);
equal(cache.previous.args, args);
start();
});
}
});
$input.val(query);
$input.triggerHandler('paste');
});

Просмотреть файл

@ -16,6 +16,8 @@
"js/zamboni/contributions.js",
"js/zamboni/password-strength.js",
"js/impala/persona_creation.js",
"js/zamboni/browserid_support.js"
"js/zamboni/browserid_support.js",
"js/impala/ajaxcache.js",
"js/impala/search.js"
]
}

Просмотреть файл

@ -0,0 +1 @@
INSERT INTO waffle_switch (name, active) VALUES ('search-suggestions', 0);

Просмотреть файл

@ -470,6 +470,7 @@ MINIFY_BUNDLES = {
'css/impala/collections.less',
'css/impala/tooltips.less',
'css/impala/search.less',
'css/impala/suggestions.less',
'css/impala/colorpicker.less',
'css/impala/personas.less',
'css/impala/login.less',
@ -600,6 +601,7 @@ MINIFY_BUNDLES = {
'js/lib/jquery-ui/jquery.ui.sortable.js',
'js/zamboni/truncation.js',
'js/impala/ajaxcache.js',
'js/zamboni/global.js',
'js/impala/global.js',
'js/impala/ratingwidget.js',
@ -644,6 +646,7 @@ MINIFY_BUNDLES = {
# Search
'js/impala/search.js',
'js/impala/suggestions.js',
# Fix-up outgoing links
'js/zamboni/outgoing_links.js',

Просмотреть файл

@ -196,6 +196,7 @@
{# js #}
{% block site_js %}
<script src="{{ static(url('jsi18n')) }}"></script>
<script src="{{ url('wafflejs') }}"></script>
{{ js('impala') }}
<script async defer src="{{ static(url('addons.buttons.js')) }}"></script>
<script async defer src="{{ settings.PAYPAL_JS_URL }}"></script>

Просмотреть файл

@ -1,6 +1,6 @@
{% set search_form = SimpleSearchForm(request, search_cat) %}
<form id="search" action="{{ search_url|default(url('search.search')) }}">
<input id="search-q" type="text" name="q" required title=""
<input id="search-q" type="text" name="q" required autocomplete="off" title=""
class="text {% if not search_form.q.data %}placeholder{% endif %}"
placeholder="{{ search_form.placeholder() }}"
value="{{ search_form.q.data or '' }}">
@ -9,4 +9,8 @@
{{ search_form.platform }}
<button class="search-button" type="submit" title="{{ _('Search') }}"
src="{{ media('img/zamboni/global/btn-search.png') }}"></button>
{% if waffle.switch('search-suggestions') %}
<div id="site-search-suggestions"
data-src="{{ url('search.suggestions') }}"></div>
{% endif %}
</form>

Просмотреть файл

@ -155,4 +155,13 @@
</form>
</div>
<div id="search-suggestions">
<form id="search" action="/en-us/firefox/search/">
<input id="search-q" type="text" name="q" required autocomplete="off" title=""
class="text placeholder" placeholder="search for add-ons" value="">
<input type="hidden" name="cat" value="all" id="id_cat">
<div id="site-search-suggestions" data-src="/askjeeves"></div>
</form>
</div>
{% endblock %}