Search Tags in Elasticsearch from Classified Listing and Post Creation (#6024) [deploy]

This commit is contained in:
Molly Struve 2020-02-17 08:16:45 -05:00 committed by GitHub
parent 3a46635c34
commit 7ff79f77b8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 139 additions and 65 deletions

View file

@ -0,0 +1,9 @@
class SearchController < ApplicationController
before_action :authenticate_user!
def tags
tag_docs = Search::Tag.search_documents("name:#{params[:name]}* AND supported:true")
render json: { result: tag_docs }
end
end

View file

@ -60,32 +60,10 @@ Object {
"prevLen": 0,
"searchResults": Array [
Object {
"_highlightResult": Object {
"bg_color_hex": Object {
"matchLevel": "none",
"matchedWords": Array [],
"value": "#888751",
},
"name": Object {
"fullyHighlighted": false,
"matchLevel": "full",
"matchedWords": Array [
"gi",
],
"value": "<em>gi</em>t",
},
"text_color_hex": Object {
"matchLevel": "none",
"matchedWords": Array [],
"value": "#56c938",
},
},
"bg_color_hex": "#888751",
"hotness_score": 0,
"name": "git",
"objectID": "4",
"short_summary": null,
"supported": true,
"text_color_hex": "#56c938",
},
],
"selectedIndex": 0,
@ -100,32 +78,10 @@ Object {
"prevLen": 0,
"searchResults": Array [
Object {
"_highlightResult": Object {
"bg_color_hex": Object {
"matchLevel": "none",
"matchedWords": Array [],
"value": "#888751",
},
"name": Object {
"fullyHighlighted": false,
"matchLevel": "full",
"matchedWords": Array [
"gi",
],
"value": "<em>gi</em>t",
},
"text_color_hex": Object {
"matchLevel": "none",
"matchedWords": Array [],
"value": "#56c938",
},
},
"bg_color_hex": "#888751",
"hotness_score": 0,
"name": "git",
"objectID": "4",
"short_summary": null,
"supported": true,
"text_color_hex": "#56c938",
},
],
"selectedIndex": 0,

View file

@ -2,15 +2,28 @@ import { h, render as preactRender } from 'preact';
import render from 'preact-render-to-json';
import { shallow } from 'preact-render-spy';
import { JSDOM } from 'jsdom';
import fetch from 'jest-fetch-mock';
import Tags from '../../../shared/components/tags';
import algoliasearch from '../__mocks__/algoliasearch';
global.fetch = fetch;
const sampleResponse = JSON.stringify({
result: [
{
name: 'git',
hotness_score: 0,
supported: true,
short_summary: null,
},
],
});
describe('<Tags />', () => {
beforeEach(() => {
const doc = new JSDOM('<!doctype html><html><body></body></html>');
global.document = doc;
global.window = doc.defaultView;
global.window.algoliasearch = algoliasearch;
fetch.mockResponse(sampleResponse);
});
it('renders properly', () => {

View file

@ -36,14 +36,6 @@ class Tags extends Component {
prevLen: 0,
showingRulesForTag: null,
};
const algoliaId = document.querySelector("meta[name='algolia-public-id']")
.content;
const algoliaKey = document.querySelector("meta[name='algolia-public-key']")
.content;
const env = document.querySelector("meta[name='environment']").content;
const client = algoliasearch(algoliaId, algoliaKey);
this.index = client.initIndex(`Tag_${env}`);
}
componentDidMount() {
@ -321,20 +313,24 @@ class Tags extends Component {
});
}
const { listing } = this.props;
return this.index
.search(query, {
hitsPerPage: 8,
attributesToHighlight: [],
filters: 'supported:true',
})
.then(content => {
return fetch(`/search/tags?name=${query}`, {
method: 'GET',
headers: {
Accept: 'application/json',
'X-CSRF-Token': window.csrfToken,
'Content-Type': 'application/json',
},
credentials: 'same-origin',
})
.then(response => response.json())
.then(response => {
if (listing === true) {
const { additionalTags } = this.state;
const { category } = this.props;
const additionalItems = (additionalTags[category] || []).filter(t =>
t.includes(query),
);
const resultsArray = content.hits;
const resultsArray = response.result;
additionalItems.forEach(t => {
if (!resultsArray.includes(t)) {
resultsArray.push({ name: t });
@ -344,7 +340,7 @@ class Tags extends Component {
// updates searchResults array according to what is being typed by user
// allows user to choose a tag when they've typed the partial or whole word
this.setState({
searchResults: content.hits,
searchResults: response.result,
});
});
}

View file

@ -17,6 +17,29 @@ module Search
SearchClient.get(id: tag_id, index: INDEX_ALIAS)
end
def search(query_string)
SearchClient.search(
index: INDEX_ALIAS,
body: {
query: {
query_string: {
query: query_string,
analyze_wildcard: true,
allow_leading_wildcard: false
}
},
sort: {
hotness_score: "desc"
}
},
)
end
def search_documents(query_string)
results = search(query_string)
results.dig("hits", "hits").map { |tag_doc| tag_doc.dig("_source") }
end
def create_index(index_name: INDEX_NAME)
SearchClient.indices.create(index: index_name, body: settings)
end
@ -25,6 +48,10 @@ module Search
SearchClient.indices.delete(index: index_name)
end
def refresh_index(index_name: INDEX_ALIAS)
SearchClient.indices.refresh(index: index_name)
end
def add_alias(index_name: INDEX_NAME, index_alias: INDEX_ALIAS)
SearchClient.indices.put_alias(index: index_name, name: index_alias)
end

View file

@ -209,6 +209,7 @@ Rails.application.routes.draw do
resources :podcasts, only: %i[new create]
resolve("ProMembership") { [:pro_membership] } # see https://guides.rubyonrails.org/routing.html#using-resolve
get "/search/tags" => "search#tags"
get "/chat_channel_memberships/find_by_chat_channel_id" => "chat_channel_memberships#find_by_chat_channel_id"
get "/listings/dashboard" => "classified_listings#dashboard"
get "/listings/:category" => "classified_listings#index"

View file

@ -4,5 +4,9 @@ FactoryBot.define do
factory :tag do
name { generate :name }
supported { true }
trait :search_indexed do
after(:create, &:index_to_elasticsearch_inline)
end
end
end

View file

@ -0,0 +1,19 @@
require "rails_helper"
RSpec.describe "Search", type: :request, proper_status: true do
describe "GET /search/tags" do
let(:authorized_user) { create(:user) }
let(:mock_documents) do
[{ "name" => "tag1" }, { "name" => "tag2" }, { "name" => "tag3" }]
end
it "returns json" do
sign_in authorized_user
allow(Search::Tag).to receive(:search_documents).and_return(
mock_documents,
)
get "/search/tags"
expect(response.parsed_body).to eq("result" => mock_documents)
end
end
end

View file

@ -10,6 +10,55 @@ RSpec.describe Search::Tag, type: :service, elasticsearch: true do
end
end
describe "::search" do
it "searches with a given query string" do
tag1 = FactoryBot.create(:tag, :search_indexed, name: "tag1")
described_class.refresh_index
hits = described_class.search("name:tag1").dig("hits", "hits")
tag_names = hits.map { |t| t.dig("_source", "name") }
expect(tag_names.count).to eq(1)
expect(tag_names).to include(tag1.name)
end
it "analyzes wildcards" do
tag1 = FactoryBot.create(:tag, :search_indexed, name: "tag1")
tag2 = FactoryBot.create(:tag, :search_indexed, name: "tag2")
tag3 = FactoryBot.create(:tag, :search_indexed, name: "3tag")
described_class.refresh_index
hits = described_class.search("name:tag*").dig("hits", "hits")
tag_names = hits.map { |t| t.dig("_source", "name") }
expect(tag_names).to include(tag1.name, tag2.name)
expect(tag_names).not_to include(tag3.name)
end
it "does not allow leading wildcards" do
bad_request_error = Elasticsearch::Transport::Transport::Errors::BadRequest
expect { described_class.search("name:*tag") }.to raise_error(bad_request_error)
end
end
describe "::search_documents" do
let(:tag_doc_1) { { "name" => "tag1" } }
let(:tag_doc_2) { { "name" => "tag2" } }
let(:mock_search_response) do
{
"hits" => {
"hits" => [
{ "_source" => tag_doc_1 },
{ "_source" => tag_doc_2 },
]
}
}
end
it "parses tag document hits from search response" do
allow(SearchClient).to receive(:search) { mock_search_response }
tag_docs = described_class.search_documents("query")
expect(tag_docs.count).to eq(2)
expect(tag_docs).to include(tag_doc_1, tag_doc_2)
end
end
describe "::find_document" do
it "fetches a document for a given ID from elasticsearch" do
tag = FactoryBot.create(:tag)