diff --git a/app/assets/javascripts/discourse/widgets/header.js.es6 b/app/assets/javascripts/discourse/widgets/header.js.es6 index 9754ad8688e..4c8df62ae5c 100644 --- a/app/assets/javascripts/discourse/widgets/header.js.es6 +++ b/app/assets/javascripts/discourse/widgets/header.js.es6 @@ -4,6 +4,7 @@ import { avatarImg } from 'discourse/widgets/post'; import DiscourseURL from 'discourse/lib/url'; import { wantsNewWindow } from 'discourse/lib/intercept-click'; import { applySearchAutocomplete } from "discourse/lib/search"; +import { ajax } from 'discourse/lib/ajax'; import { h } from 'virtual-dom'; @@ -249,7 +250,29 @@ export default createWidget('header', { }, linkClickedEvent(attrs) { - if (!(attrs && attrs.searchContextEnabled)) this.closeAll(); + + let searchContextEnabled = false; + if (attrs) { + searchContextEnabled = attrs.searchContextEnabled; + + const { searchLogId, searchResultId, searchResultType } = attrs; + if (searchLogId && searchResultId && searchResultType) { + + ajax('/search/click', { + type: 'POST', + data: { + search_log_id: searchLogId, + search_result_id: searchResultId, + search_result_type: searchResultType + } + }); + } + } + + if (!searchContextEnabled) { + this.closeAll(); + } + this.updateHighlight(); }, diff --git a/app/assets/javascripts/discourse/widgets/search-menu-results.js.es6 b/app/assets/javascripts/discourse/widgets/search-menu-results.js.es6 index 41216e414e4..1e2a6a12451 100644 --- a/app/assets/javascripts/discourse/widgets/search-menu-results.js.es6 +++ b/app/assets/javascripts/discourse/widgets/search-menu-results.js.es6 @@ -20,18 +20,26 @@ class Highlighted extends RawHtml { } } -function createSearchResult(type, linkField, fn) { +function createSearchResult({ type, linkField, builder }) { return createWidget(`search-result-${type}`, { html(attrs) { return attrs.results.map(r => { + + let searchResultId; + if (type === "topic") { + searchResultId = r.get('topic_id'); + } return h('li', this.attach('link', { href: r.get(linkField), - contents: () => fn.call(this, r, attrs.term), + contents: () => builder.call(this, r, attrs.term), className: 'search-link', - searchContextEnabled: this.attrs.searchContextEnabled + searchResultId, + searchResultType: type, + searchContextEnabled: attrs.searchContextEnabled, + searchLogId: attrs.searchLogId })); }); - } + }, }); } @@ -47,27 +55,43 @@ function postResult(result, link, term) { return html; } -createSearchResult('user', 'path', function(u) { - return [ avatarImg('small', { template: u.avatar_template, username: u.username }), ' ', h('span.user-results', h('b', u.username)), ' ', h('span.user-results', u.name ? u.name : '') ]; +createSearchResult({ + type: 'user', + linkField: 'path', + builder(u) { + return [ avatarImg('small', { template: u.avatar_template, username: u.username }), ' ', h('span.user-results', h('b', u.username)), ' ', h('span.user-results', u.name ? u.name : '') ]; + } }); -createSearchResult('topic', 'url', function(result, term) { - const topic = result.topic; - const link = h('span.topic', [ - this.attach('topic-status', { topic, disableActions: true }), - h('span.topic-title', new Highlighted(topic.get('fancyTitle'), term)), - this.attach('category-link', { category: topic.get('category'), link: false }) - ]); +createSearchResult({ + type: 'topic', + linkField: 'url', + builder(result, term) { + const topic = result.topic; + const link = h('span.topic', [ + this.attach('topic-status', { topic, disableActions: true }), + h('span.topic-title', new Highlighted(topic.get('fancyTitle'), term)), + this.attach('category-link', { category: topic.get('category'), link: false }) + ]); - return postResult.call(this, result, link, term); + return postResult.call(this, result, link, term); + } }); -createSearchResult('post', 'url', function(result, term) { - return postResult.call(this, result, I18n.t('search.post_format', result), term); +createSearchResult({ + type: 'post', + linkField: 'url', + builder(result, term) { + return postResult.call(this, result, I18n.t('search.post_format', result), term); + } }); -createSearchResult('category', 'url', function (c) { - return this.attach('category-link', { category: c, link: false }); +createSearchResult({ + type: 'category', + linkField: 'url', + builder(c) { + return this.attach('category-link', { category: c, link: false }); + } }); createWidget('search-menu-results', { @@ -102,7 +126,8 @@ createWidget('search-menu-results', { return [ h('ul', this.attach(rt.componentName, { - searchContextEnabled: this.attrs.searchContextEnabled, + searchContextEnabled: attrs.searchContextEnabled, + searchLogId: attrs.results.grouped_search_result.search_log_id, results: rt.results, term: attrs.term })), diff --git a/app/assets/javascripts/discourse/widgets/search-menu.js.es6 b/app/assets/javascripts/discourse/widgets/search-menu.js.es6 index ba68383e59d..d5e9b12e9ac 100644 --- a/app/assets/javascripts/discourse/widgets/search-menu.js.es6 +++ b/app/assets/javascripts/discourse/widgets/search-menu.js.es6 @@ -127,11 +127,13 @@ export default createWidget('search-menu', { if (searchData.loading) { results.push(h('div.searching', h('div.spinner'))); } else { - results.push(this.attach('search-menu-results', { term: searchData.term, - noResults: searchData.noResults, - results: searchData.results, - invalidTerm: searchData.invalidTerm, - searchContextEnabled: searchData.contextEnabled })); + results.push(this.attach('search-menu-results', { + term: searchData.term, + noResults: searchData.noResults, + results: searchData.results, + invalidTerm: searchData.invalidTerm, + searchContextEnabled: searchData.contextEnabled + })); } } diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb index 42ddd699077..1c29e71fa55 100644 --- a/app/controllers/search_controller.rb +++ b/app/controllers/search_controller.rb @@ -67,6 +67,27 @@ class SearchController < ApplicationController render_serialized(result, GroupedSearchResultSerializer, result: result) end + def click + params.require(:search_log_id) + params.require(:search_result_type) + params.require(:search_result_id) + + if params[:search_result_type] == 'topic' + where = { id: params[:search_log_id] } + if current_user.present? + where[:user_id] = current_user.id + else + where[:ip_address] = request.remote_ip + end + + SearchLog.where(where).update_all( + clicked_topic_id: params[:search_result_id] + ) + end + + render json: success_json + end + protected def lookup_search_context diff --git a/app/serializers/grouped_search_result_serializer.rb b/app/serializers/grouped_search_result_serializer.rb index 44b833140c6..e3cfbe855f0 100644 --- a/app/serializers/grouped_search_result_serializer.rb +++ b/app/serializers/grouped_search_result_serializer.rb @@ -8,4 +8,8 @@ class GroupedSearchResultSerializer < ApplicationSerializer object.search_log_id end + def include_search_log_id? + search_log_id.present? + end + end diff --git a/config/routes.rb b/config/routes.rb index ebc92dc8df5..b3efb5f486f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -550,6 +550,7 @@ Discourse::Application.routes.draw do get "top" => "list#top" get "search/query" => "search#query" get "search" => "search#show" + post "search/click" => "search#click" # Topics resource get "t/:id" => "topics#show" diff --git a/spec/controllers/search_controller_spec.rb b/spec/controllers/search_controller_spec.rb index b14034c10fb..6eabf54956d 100644 --- a/spec/controllers/search_controller_spec.rb +++ b/spec/controllers/search_controller_spec.rb @@ -123,4 +123,89 @@ describe SearchController do end + context "#click" do + it "doesn't work wthout the necessary parameters" do + expect(-> { + xhr :post, :click + }).to raise_error(ActionController::ParameterMissing) + end + + it "doesn't record the click for a different user" do + log_in(:user) + + _, search_log_id = SearchLog.log( + term: 'kitty', + search_type: :header, + user_id: -10, + ip_address: '127.0.0.1' + ) + + xhr :post, :click, { + search_log_id: search_log_id, + search_result_id: 12345, + search_result_type: 'topic' + } + expect(response).to be_success + + expect(SearchLog.find(search_log_id).clicked_topic_id).to be_blank + end + + it "records the click for a logged in user" do + user = log_in(:user) + + _, search_log_id = SearchLog.log( + term: 'kitty', + search_type: :header, + user_id: user.id, + ip_address: '127.0.0.1' + ) + + xhr :post, :click, { + search_log_id: search_log_id, + search_result_id: 12345, + search_result_type: 'topic' + } + expect(response).to be_success + + expect(SearchLog.find(search_log_id).clicked_topic_id).to eq(12345) + end + + it "records the click for an anonymous user" do + request.stubs(:remote_ip).returns('192.168.0.1') + + _, search_log_id = SearchLog.log( + term: 'kitty', + search_type: :header, + ip_address: '192.168.0.1' + ) + + xhr :post, :click, { + search_log_id: search_log_id, + search_result_id: 22222, + search_result_type: 'topic' + } + expect(response).to be_success + + expect(SearchLog.find(search_log_id).clicked_topic_id).to eq(22222) + end + + it "doesn't record the click for a different IP" do + request.stubs(:remote_ip).returns('192.168.0.2') + + _, search_log_id = SearchLog.log( + term: 'kitty', + search_type: :header, + ip_address: '192.168.0.1' + ) + + xhr :post, :click, { + search_log_id: search_log_id, + search_result_id: 22222, + search_result_type: 'topic' + } + expect(response).to be_success + + expect(SearchLog.find(search_log_id).clicked_topic_id).to be_blank + end + end end diff --git a/test/javascripts/acceptance/search-test.js.es6 b/test/javascripts/acceptance/search-test.js.es6 index 475c164137f..c3d6f11e81b 100644 --- a/test/javascripts/acceptance/search-test.js.es6 +++ b/test/javascripts/acceptance/search-test.js.es6 @@ -72,4 +72,4 @@ QUnit.test("Search with context", assert => { andThen(() => { assert.ok(!$('.search-context input[type=checkbox]').is(":checked")); }); -}); \ No newline at end of file +});