From 8bc86eb98bba3481abc5514b08b5faeee8894065 Mon Sep 17 00:00:00 2001 From: Steffen van Bergerem Date: Sun, 2 Aug 2015 01:34:28 +0200 Subject: [PATCH] Replace jquery.autocomplete with typeahead.js --- Gemfile | 1 + Gemfile.lock | 3 + .../javascripts/app/views/header_view.js | 13 - .../javascripts/app/views/search_view.js | 138 ++-- app/assets/javascripts/main.js | 2 +- app/assets/javascripts/view.js | 18 - app/assets/stylesheets/_application.scss | 2 +- app/assets/stylesheets/autocomplete.scss | 95 --- app/assets/stylesheets/typeahead.scss | 30 + app/assets/templates/header_tpl.jst.hbs | 3 +- .../templates/search_suggestion_tpl.jst.hbs | 13 + config/.jshint.json | 1 + features/desktop/search.feature | 45 +- features/step_definitions/profile_steps.rb | 4 + features/step_definitions/search_steps.rb | 12 +- .../javascripts/app/views/header_view_spec.js | 31 - .../javascripts/app/views/search_view_spec.js | 80 +- .../javascripts/jquery.autocomplete-custom.js | 763 ------------------ 18 files changed, 252 insertions(+), 1002 deletions(-) delete mode 100644 app/assets/stylesheets/autocomplete.scss create mode 100644 app/assets/stylesheets/typeahead.scss create mode 100644 app/assets/templates/search_suggestion_tpl.jst.hbs delete mode 100644 vendor/assets/javascripts/jquery.autocomplete-custom.js diff --git a/Gemfile b/Gemfile index 9ccad44a7..2683adc4f 100644 --- a/Gemfile +++ b/Gemfile @@ -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 diff --git a/Gemfile.lock b/Gemfile.lock index cf28ab54b..108d579e4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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) diff --git a/app/assets/javascripts/app/views/header_view.js b/app/assets/javascripts/app/views/header_view.js index 6434f1a89..5b682c3b3 100644 --- a/app/assets/javascripts/app/views/header_view.js +++ b/app/assets/javascripts/app/views/header_view.js @@ -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 diff --git a/app/assets/javascripts/app/views/search_view.js b/app/assets/javascripts/app/views/search_view.js index aed2e54ca..6e8a1d272 100644 --- a/app/assets/javascripts/app/views/search_view.js +++ b/app/assets/javascripts/app/views/search_view.js @@ -1,71 +1,95 @@ // @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 += ''; } - item += row.name; - if (row.handle) { item += '
' + row.handle + '
'; } - return item; - } - }, - - formatResult: function(row){ return Handlebars.Utils.escapeExpression(row.name); }, - - parse: function(data) { - var self = this.context; - - var results = data.map(function(person){ - person.name = self.formatResult(person); - return {data : person, value : person.name}; - }); - - results.push({ - data: { - name: self.searchInput.val(), - url: self.searchFormAction + '?' + self.searchInputName + '=' + self.searchInput.val(), - search: true + 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); }, - value: self.searchInput.val() + 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 }); - - return results; }, - selectItemCallback: function(evt, data, formatted){ - var self = this.context; + 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 */ + } + }); + }, - 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); + transformBloodhoundResponse: function(response) { + var result = response.map(function(data) { + // person + if(data.handle) { + data.person = true; + return data; + } + + // hashtag + return { + hashtag: true, + name: data.name, + url: Routes.tag(data.name.substring(1)) + }; + }); + + return result; + }, + + 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); + }, + + suggestionSelected: function(evt, datum) { + window.location = datum.url; + }, + + inputKeypress: function(evt) { + if(evt.which === 13 && $(".tt-suggestion.tt-cursor").length === 0) { + $(evt.target).closest("form").submit(); } } }); diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index 8ee4dfc2b..cbad4371f 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -17,7 +17,6 @@ //= 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 +34,7 @@ //= require markdown-it-sup //= require highlightjs //= require clear-form +//= require typeahead.js //= require app/app //= require diaspora //= require_tree ./helpers diff --git a/app/assets/javascripts/view.js b/app/assets/javascripts/view.js index 5f43ad4dd..4810a8a69 100644 --- a/app/assets/javascripts/view.js +++ b/app/assets/javascripts/view.js @@ -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"); diff --git a/app/assets/stylesheets/_application.scss b/app/assets/stylesheets/_application.scss index a0b6d7d0c..585929f2b 100644 --- a/app/assets/stylesheets/_application.scss +++ b/app/assets/stylesheets/_application.scss @@ -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'; diff --git a/app/assets/stylesheets/autocomplete.scss b/app/assets/stylesheets/autocomplete.scss deleted file mode 100644 index 051dd6580..000000000 --- a/app/assets/stylesheets/autocomplete.scss +++ /dev/null @@ -1,95 +0,0 @@ -.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; - } -} diff --git a/app/assets/stylesheets/typeahead.scss b/app/assets/stylesheets/typeahead.scss new file mode 100644 index 000000000..4c08bc219 --- /dev/null +++ b/app/assets/stylesheets/typeahead.scss @@ -0,0 +1,30 @@ +.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; } + } +} diff --git a/app/assets/templates/header_tpl.jst.hbs b/app/assets/templates/header_tpl.jst.hbs index f00f23729..e835337a1 100644 --- a/app/assets/templates/header_tpl.jst.hbs +++ b/app/assets/templates/header_tpl.jst.hbs @@ -101,8 +101,7 @@ diff --git a/app/assets/templates/search_suggestion_tpl.jst.hbs b/app/assets/templates/search_suggestion_tpl.jst.hbs new file mode 100644 index 000000000..a2c734cab --- /dev/null +++ b/app/assets/templates/search_suggestion_tpl.jst.hbs @@ -0,0 +1,13 @@ +{{#if person}} +
+ {{#if avatar}} + + {{/if}} +
{{ name }}
+
{{ handle }}
+
+{{else}}{{#if hashtag}} +
+
{{ name }}
+
+{{/if}}{{/if}} diff --git a/config/.jshint.json b/config/.jshint.json index 86bcc1cf2..676c0f1f6 100644 --- a/config/.jshint.json +++ b/config/.jshint.json @@ -40,6 +40,7 @@ "_", "autosize", "Backbone", + "Bloodhound", "gon", "Handlebars", "HandlebarsTemplates", diff --git a/features/desktop/search.feature b/features/desktop/search.feature index da49164df..8b436018f 100644 --- a/features/desktop/search.feature +++ b/features/desktop/search.feature @@ -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" diff --git a/features/step_definitions/profile_steps.rb b/features/step_definitions/profile_steps.rb index 28a5a3489..af9949d93 100644 --- a/features/step_definitions/profile_steps.rb +++ b/features/step_definitions/profile_steps.rb @@ -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 diff --git a/features/step_definitions/search_steps.rb b/features/step_definitions/search_steps.rb index c623c202d..18b499989 100644 --- a/features/step_definitions/search_steps.rb +++ b/features/step_definitions/search_steps.rb @@ -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 diff --git a/spec/javascripts/app/views/header_view_spec.js b/spec/javascripts/app/views/header_view_spec.js index 1106d6d6f..2b1987ff8 100644 --- a/spec/javascripts/app/views/header_view_spec.js +++ b/spec/javascripts/app/views/header_view_spec.js @@ -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"); - }); - }); - }); }); diff --git a/spec/javascripts/app/views/search_view_spec.js b/spec/javascripts/app/views/search_view_spec.js index ca23e6f7c..19fc0ccfb 100644 --- a/spec/javascripts/app/views/search_view_spec.js +++ b/spec/javascripts/app/views/search_view_spec.js @@ -1,14 +1,78 @@ describe("app.views.Search", function() { beforeEach(function(){ - spec.content().html('
'); - this.view = new app.views.Search({ el: '#search_people_form' }); + spec.content().html( + "
" + ); }); - describe("parse", function() { - it("escapes a persons name", function() { - var person = { 'name': '