Commit bd707c0d authored by Jonne Haß's avatar Jonne Haß

Merge pull request #6293 from svbergerem/typeahead

Replace jquery.autocomplete with typeahead.js
parents 25be9ecf e8acaa08
......@@ -46,6 +46,7 @@ With the port to Bootstrap 3, app/views/terms/default.haml has a new structure.
* Improve mobile drawer transition [#6233](https://github.com/diaspora/diaspora/pull/6233)
* Remove unused header icons and an unused favicon [#6283](https://github.com/diaspora/diaspora/pull/6283)
* Replace mobile icons for post interactions with Entypo icons [#6291](https://github.com/diaspora/diaspora/pull/6291)
* Replace jquery.autocomplete with typeahead.js [#6293](https://github.com/diaspora/diaspora/pull/6293)
## Bug fixes
* Destroy Participation when removing interactions with a post [#5852](https://github.com/diaspora/diaspora/pull/5852)
......
......@@ -104,6 +104,7 @@ source "https://rails-assets.org" do
gem "rails-assets-markdown-it-sub", "1.0.0"
gem "rails-assets-markdown-it-sup", "1.0.0"
gem "rails-assets-highlightjs", "8.6.0"
gem "rails-assets-typeahead.js", "0.11.1"
# jQuery plugins
......
......@@ -569,6 +569,8 @@ GEM
rails-assets-markdown-it-sub (1.0.0)
rails-assets-markdown-it-sup (1.0.0)
rails-assets-perfect-scrollbar (0.6.4)
rails-assets-typeahead.js (0.11.1)
rails-assets-jquery (>= 1.7)
rails-deprecated_sanitizer (1.0.3)
activesupport (>= 4.2.0.alpha)
rails-dom-testing (1.0.6)
......@@ -860,6 +862,7 @@ DEPENDENCIES
rails-assets-markdown-it-sub (= 1.0.0)!
rails-assets-markdown-it-sup (= 1.0.0)!
rails-assets-perfect-scrollbar (= 0.6.4)!
rails-assets-typeahead.js (= 0.11.1)!
rails-i18n (= 4.0.4)
rails-timeago (= 2.11.0)
rails_admin (= 0.6.8)
......
......@@ -6,11 +6,6 @@ app.views.Header = app.views.Base.extend({
className: "dark-header",
events: {
"focusin #q": "toggleSearchActive",
"focusout #q": "toggleSearchActive"
},
presenter: function() {
return _.extend({}, this.defaultPresenter(), {
podname: gon.appConfig.settings.podname
......@@ -24,13 +19,5 @@ app.views.Header = app.views.Base.extend({
},
menuElement: function(){ return this.$("ul.dropdown"); },
toggleSearchActive: function(evt){
// jQuery produces two events for focus/blur (for bubbling)
// don't rely on which event arrives first, by allowing for both variants
var isActive = (_.indexOf(["focus","focusin"], evt.type) !== -1);
$(evt.target).toggleClass("active", isActive);
return false;
}
});
// @license-end
// @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-v3-or-Later
app.views.Search = app.views.Base.extend({
events: {
"focusin #q": "toggleSearchActive",
"focusout #q": "toggleSearchActive",
"keypress #q": "inputKeypress",
},
initialize: function(){
this.searchFormAction = this.$el.attr('action');
this.searchInput = this.$('input[type="search"]');
this.searchInputName = this.$('input[type="search"]').attr('name');
this.searchInputHandle = this.$('input[type="search"]').attr('handle');
this.options = {
cacheLength: 15,
delay: 800,
extraParams: {limit: 4},
formatItem: this.formatItem,
formatResult: this.formatResult,
max: 5,
minChars: 2,
onSelect: this.selectItemCallback,
parse: this.parse,
scroll: false,
context: this
};
this.searchFormAction = this.$el.attr("action");
this.searchInput = this.$("#q");
var self = this;
this.searchInput.autocomplete(self.searchFormAction + '.json',
$.extend(self.options, { element: self.searchInput }));
// constructs the suggestion engine
this.setupBloodhound();
this.setupTypeahead();
this.searchInput.on("typeahead:select", this.suggestionSelected);
},
formatItem: function(row){
if(typeof row.search !== 'undefined') { return Diaspora.I18n.t('search_for', row); }
else {
var item = '';
if (row.avatar) { item += '<img src="' + row.avatar + '" class="avatar"/>'; }
item += row.name;
if (row.handle) { item += '<div class="search_handle">' + row.handle + '</div>'; }
return item;
}
setupBloodhound: function() {
this.bloodhound = new Bloodhound({
datumTokenizer: function(datum) {
var nameTokens = Bloodhound.tokenizers.nonword(datum.name);
var handleTokens = datum.handle ? Bloodhound.tokenizers.nonword(datum.name) : [];
return nameTokens.concat(handleTokens);
},
queryTokenizer: Bloodhound.tokenizers.whitespace,
remote: {
url: this.searchFormAction + ".json?q=%QUERY",
wildcard: "%QUERY",
transform: this.transformBloodhoundResponse
},
prefetch: {
url: "/contacts.json",
transform: this.transformBloodhoundResponse,
cache: false
},
sufficient: 5
});
},
formatResult: function(row){ return Handlebars.Utils.escapeExpression(row.name); },
setupTypeahead: function() {
this.searchInput.typeahead({
hint: false,
highlight: true,
minLength: 2
},
{
name: "search",
display: "name",
limit: 5,
source: this.bloodhound,
templates: {
/* jshint camelcase: false */
suggestion: HandlebarsTemplates.search_suggestion_tpl
/* jshint camelcase: true */
}
});
},
parse: function(data) {
var self = this.context;
transformBloodhoundResponse: function(response) {
var result = response.map(function(data) {
// person
if(data.handle) {
data.person = true;
return data;
}
var results = data.map(function(person){
person.name = self.formatResult(person);
return {data : person, value : person.name};
// hashtag
return {
hashtag: true,
name: data.name,
url: Routes.tag(data.name.substring(1))
};
});
results.push({
data: {
name: self.searchInput.val(),
url: self.searchFormAction + '?' + self.searchInputName + '=' + self.searchInput.val(),
search: true
},
value: self.searchInput.val()
});
return result;
},
return results;
toggleSearchActive: function(evt) {
// jQuery produces two events for focus/blur (for bubbling)
// don't rely on which event arrives first, by allowing for both variants
var isActive = (_.indexOf(["focus","focusin"], evt.type) !== -1);
$(evt.target).toggleClass("active", isActive);
},
selectItemCallback: function(evt, data, formatted){
var self = this.context;
suggestionSelected: function(evt, datum) {
window.location = datum.url;
},
if(data.search === true){
window.location = self.searchFormAction + '?' + self.searchInputName + '=' + data.name;
}
else{ // The actual result
self.options.element.val(formatted);
window.location = data.url ? data.url : '/tags/' + data.name.substring(1);
inputKeypress: function(evt) {
if(evt.which === 13 && $(".tt-suggestion.tt-cursor").length === 0) {
$(evt.target).closest("form").submit();
}
}
});
......
// @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-v3-or-Later
jQuery.browser = {};
jQuery.browser.mozilla = /mozilla/.test(navigator.userAgent.toLowerCase()) && !/webkit/.test(navigator.userAgent.toLowerCase());
jQuery.browser.webkit = /webkit/.test(navigator.userAgent.toLowerCase());
jQuery.browser.opera = /opera/.test(navigator.userAgent.toLowerCase());
jQuery.browser.msie = /msie/.test(navigator.userAgent.toLowerCase());
// @license-end
......@@ -12,12 +12,10 @@
//= require jquery.charcount
//= require jquery-placeholder
//= require rails-timeago
//= require browser_detection
//= require jquery.events.input
//= require jakobmattsson-jquery-elastic
//= require jquery.mentionsInput
//= require jquery.infinitescroll-custom
//= require jquery.autocomplete-custom
//= require jquery-ui/core
//= require jquery-ui/widget
//= require jquery-ui/mouse
......@@ -35,6 +33,7 @@
//= require markdown-it-sup
//= require highlightjs
//= require clear-form
//= require typeahead.js
//= require app/app
//= require diaspora
//= require_tree ./helpers
......
......@@ -7,14 +7,6 @@ var View = {
/* label placeholders */
$("input, textarea").placeholder();
/* "Toggling" the search input */
$(this.search.selector)
.blur(this.search.blur)
.focus(this.search.focus)
/* Submit the form when the user hits enter */
.keypress(this.search.keyPress);
/* Dropdowns */
$(document)
.on('click', this.dropdowns.selector, this.dropdowns.click)
......@@ -48,16 +40,6 @@ var View = {
});
},
search: {
blur: function() {
$(this).removeClass("active");
},
focus: function() {
$(this).addClass("active");
},
selector: "#q"
},
dropdowns: {
click: function(evt) {
$(this).parent('.dropdown').toggleClass("active");
......
......@@ -116,12 +116,6 @@ jQuery.fn.center = (function() {
images = selectedImage.parents(self.options.imageParent).find(self.options.imageSelector),
imageThumb;
if( $.browser.msie ) {
/* No fancy schmancy lightbox for IE, because it doesn't work in IE */
window.open(imageUrl);
return;
}
self.imageset.html("");
images.each(function(index, image) {
image = $(image);
......
......@@ -7,7 +7,6 @@
/* core */
@import 'media-box';
@import 'autocomplete';
@import 'entypo';
@import 'icons';
@import 'mentions';
......@@ -21,6 +20,7 @@
@import 'timeago';
@import 'vendor/fileuploader';
@import 'vendor/autoSuggest';
@import 'typeahead';
/* font overrides */
@import 'new_styles/typography';
......
.ac_results {
border: 1px solid #999;
background-color: transparent;
overflow: hidden;
z-index: 99999;
min-width: 300px !important;
width: 100%;
border-radius: 3px;
box-shadow: 0 1px 3px #999;
}
.ac_results ul {
width: 100%;
list-style-position: outside;
list-style: none;
padding: 0;
margin: 0;
}
.ac_results li {
color: white;
margin: 0px;
padding: 2px 5px {
left: 50px;
top: 6px;
}
cursor: default;
display: block;
height: 45px;
position: relative;
// if width will be 100% horizontal scrollbar will apear
// when scroll mode will be used
// width 100%
font: menu;
font-size: 1em;
// it is very important, if line-height not setted or setted
// in relative units scroll will be broken in firefox
//:line-height 16px
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.ac_input + .spinner {
display: none;
}
.ac_input.ac_loading + .spinner {
display: inline-block;
height: 18px;
margin-left: -26px;
margin-right: 8px;
margin-top: 7px;
position: absolute;
width: 18px;
}
.ac_odd {
background-color: $navbar-inverse-bg;
}
.ac_even {
background-color: darken($navbar-inverse-bg, 3%);
}
.ac_over {
background-color: $brand-primary;
}
.ac_results {
.avatar {
height: 35px;
width: 35px;
position: absolute;
left: 5px;
top: 5px;
}
.search_handle {
font-size: 0.8em;
color: #999;
margin-top: -3px;
}
.ac_over .search_handle{
color: #fff;
}
.ac_over .search_handle, .search_handle {
display: block;
overflow: hidden;
text-overflow: ellipsis;
}
}
.tt-menu {
width: 300px;
margin-top: 11px;
background-color: $navbar-inverse-bg;
box-shadow: 0 5px 10px rgba(0,0,0,.2);
}
.tt-suggestion {
border-top: 1px solid $gray-dark;
color: $white;
cursor: pointer;
line-height: 20px;
&.tt-cursor {
background-color: $brand-primary;
}
&.search-suggestion-person {
padding: 8px;
.avatar {
height: 40px;
margin-right: 8px;
width: 40px;
}
.diaspora-id { font-size: $font-size-small; }
}
&.search-suggestion-hashtag {
padding: 8px 20px;
.name { line-height: 25px; }
}
}
......@@ -101,8 +101,7 @@
<form id="header-search-form" accept-charset="UTF-8" action="/search" class="navbar-form navbar-right" role="search" method="get">
<div class="form-group">
<input id="q" name="q" placeholder="{{t "header.search"}}" results="5" type="search" autocomplete="off" class="ac_input form-control input-sm">
<div class="spinner"></div>
<input id="q" name="q" placeholder="{{t "header.search"}}" results="5" type="search" autocomplete="off" class="form-control input-sm">
</div>
<input name="utf8" type="hidden" value="✓">
</form>
......
{{#if person}}
<div class="search-suggestion-person">
{{#if avatar}}
<img src="{{ avatar }}" class="avatar pull-left">
{{/if}}
<div class="name">{{ name }}</div>
<div class="diaspora-id">{{ handle }}</div>
</div>
{{else}}{{#if hashtag}}
<div class="search-suggestion-hashtag">
<div class="name">{{ name }}</div>
</div>
{{/if}}{{/if}}
......@@ -40,6 +40,7 @@
"_",
"autosize",
"Backbone",
"Bloodhound",
"gon",
"Handlebars",
"HandlebarsTemplates",
......
......@@ -6,28 +6,51 @@ Feature: search for users and hashtags
Background:
Given following users exist:
| username | email |
| Bob Jones | bob@bob.bob |
| Alice Smith | alice@alice.alice |
And I sign in as "bob@bob.bob"
| username | email |
| Bob Jones | bob@bob.bob |
| Alice Smith | alice@alice.alice |
| Carol Williams | carol@example.com |
Scenario: search for a user and go to its profile
When I enter "Alice Sm" in the search input
Then I should see "Alice Smith" within ".ac_results"
When I sign in as "bob@bob.bob"
And I enter "Alice Sm" in the search input
Then I should see "Alice Smith" within ".tt-menu"
When I click on the first search result
Then I should see "Alice Smith" within ".profile_header #name"
Scenario: search for a inexistent user and go to the search page
When I enter "Trinity" in the search input
Then I should see "Search for Trinity" within ".ac_even"
When I sign in as "bob@bob.bob"
And I enter "Trinity" in the search input
And I press enter in the search input
When I click on the first search result
Then I should see "Users matching Trinity" within "#search_title"
Scenario: search for a not searchable user
When I sign in as "carol@example.com"
And I go to the edit profile page
And I mark myself as not searchable
And I submit the form
Then I should be on the edit profile page
And the "profile[searchable]" checkbox should not be checked
When I sign out
And I sign in as "bob@bob.bob"
And I enter "Carol Wi" in the search input
Then I should not see any search results
Given a user with email "bob@bob.bob" is connected with "carol@example.com"
When I go to the home page
And I enter "Carol Wi" in the search input
Then I should see "Carol Williams" within ".tt-menu"
When I click on the first search result
Then I should see "Carol Williams" within ".profile_header #name"
Scenario: search for a tag
When I enter "#Matrix" in the search input
Then I should see "#matrix" within ".ac_even"
When I sign in as "bob@bob.bob"
And I enter "#Matrix" in the search input
Then I should see "#Matrix" within ".tt-menu"
When I click on the first search result
Then I should be on the tag page for "matrix"
......@@ -6,6 +6,10 @@ And /^I mark myself as safe for work$/ do
uncheck('profile[nsfw]')
end
And /^I mark myself as not searchable$/ do
uncheck("profile[searchable]")
end
When(/^I delete a photo$/) do
find('.photo.loaded .thumbnail', :match => :first).hover
find('.delete', :match => :first).click
......
......@@ -3,7 +3,15 @@ When /^I enter "([^"]*)" in the search input$/ do |search_term|
end
When /^I click on the first search result$/ do
within(".ac_results") do
find("li", match: :first).click
within(".tt-menu") do
find(".tt-suggestion", match: :first).click
end
end
When /^I press enter in the search input$/ do
find("input#q").native.send_keys :return
end
Then /^I should not see any search results$/ do
expect(page).to_not have_selector(".tt-suggestion")
end
......@@ -54,35 +54,4 @@ describe("app.views.Header", function() {
});
});
});
describe("search", function() {
var input;
beforeEach(function() {
$("#jasmine_content").html(this.view.el);
input = $(this.view.el).find("#q");
});
describe("focus", function() {
beforeEach(function(done){
input.trigger("focusin");
done();
});
it("adds the class 'active' when the user focuses the text field", function() {
expect(input).toHaveClass("active");
});
});
describe("blur", function() {
beforeEach(function(done) {
input.trigger("focusin").trigger("focusout");
done();
});
it("removes the class 'active' when the user blurs the text field", function() {
expect(input).not.toHaveClass("active");
});
});
});
});
describe("app.views.Search", function() {
beforeEach(function(){
spec.content().html('<form action="#" id="search_people_form"></form>');
this.view = new app.views.Search({ el: '#search_people_form' });
});
describe("parse", function() {
it("escapes a persons name", function() {
var person = { 'name': '</script><script>alert("xss");</script' };
this.view.context = this.view;
var result = this.view.parse([$.extend({}, person)]);
expect(result[0].data.name).not.toEqual(person.name);
spec.content().html(
"<form action='/search' id='search_people_form'><input id='q' name='q' type='search'></input></form>"
);
});
describe("initialize", function() {
it("calls setupBloodhound", function() {
spyOn(app.views.Search.prototype, "setupBloodhound").and.callThrough();
new app.views.Search({ el: "#search_people_form" });
expect(app.views.Search.prototype.setupBloodhound).toHaveBeenCalled();
});
it("calls setupTypeahead", function() {
spyOn(app.views.Search.prototype, "setupTypeahead");
new app.views.Search({ el: "#search_people_form" });
expect(app.views.Search.prototype.setupTypeahead).toHaveBeenCalled();
});
});
describe("toggleSearchActive", function() {
beforeEach(function() {
this.view = new app.views.Search({ el: "#search_people_form" });
this.typeaheadInput = this.view.$("#q");
});
context("focus", function() {
it("adds the class 'active' when the user focuses the text field", function() {
expect(this.typeaheadInput).not.toHaveClass("active");
this.typeaheadInput.trigger("focusin");
expect(this.typeaheadInput).toHaveClass("active");
});
});
context("blur", function() {
beforeEach(function() {
this.typeaheadInput.addClass("active");
});
it("removes the class 'active' when the user blurs the text field", function() {
this.typeaheadInput.trigger("focusout");
expect(this.typeaheadInput).not.toHaveClass("active");
});
});
});
describe("transformBloodhoundResponse" , function() {
beforeEach(function() {
this.view = new app.views.Search({ el: "#search_people_form" });
});
context("with persons", function() {
beforeEach(function() {
this.response = [{name: "Person", handle: "person@pod.tld"},{name: "User", handle: "user@pod.tld"}];
});
it("sets data.person to true", function() {
expect(this.view.transformBloodhoundResponse(this.response)).toEqual([
{name: "Person", handle: "person@pod.tld", person: true},
{name: "User", handle: "user@pod.tld", person: true}
]);
});
});
context("with hashtags", function() {
beforeEach(function() {
this.response = [{name: "#tag"}, {name: "#hashTag"}];
});
it("sets data.hashtag to true and adds the correct URL", function() {
expect(this.view.transformBloodhoundResponse(this.response)).toEqual([
{name: "#tag", hashtag: true, url: Routes.tag("tag")},
{name: "#hashTag", hashtag: true, url: Routes.tag("hashTag")}
]);
});
});
});
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment