[deploy] Feature: Serve Reading List from FeedContent Index (#10294)

* Feature:Serve Reading List from FeedContent Index
* Fix paging, order ID desc, improve comments
This commit is contained in:
Molly Struve 2020-09-17 17:20:14 -05:00 committed by GitHub
parent 9272da5ee8
commit 86cb66e858
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 230 additions and 12 deletions

View file

@ -103,8 +103,8 @@ class SearchController < ApplicationController
end
def reactions
result = Search::Reaction.search_documents(
params: reaction_params.merge(user_id: current_user.id).to_h,
result = Search::ReadingList.search_documents(
params: reaction_params.to_h, user: current_user,
)
render json: { result: result["reactions"], total: result["total"] }

View file

@ -18,7 +18,7 @@ describe('<ReadingList />', () => {
body_text: 'Some body text',
class_name: 'Article',
path: '/bobbytables/what-s-in-your-database-2d3f',
published_date_string: 'Jun 22',
readable_publish_date_string: 'Jun 22',
reading_time: 0,
tags: [
{

View file

@ -7,7 +7,7 @@ export const ItemListItem = ({ item, children }) => {
path: item.reactable.path,
title: item.reactable.title,
user: item.reactable.user,
publishedDate: item.reactable.published_date_string,
publishedDate: item.reactable.readable_publish_date_string,
readingTime: item.reactable.reading_time,
tags: item.reactable.tags,
};
@ -59,7 +59,7 @@ const readingListItemPropTypes = PropTypes.shape({
path: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
reading_time: PropTypes.number.isRequired,
published_date_string: PropTypes.string.isRequired,
readable_publish_date_string: PropTypes.string.isRequired,
user: PropTypes.shape({
username: PropTypes.string.isRequired,
profile_image_90: PropTypes.string.isRequired,

View file

@ -9,7 +9,7 @@ function getItem() {
reactable: {
path: '/article',
title: 'Title',
published_date_string: 'Jun 29',
readable_publish_date_string: 'Jun 29',
reading_time: 1,
user: {
username: 'bob',

View file

@ -139,6 +139,7 @@ export function loadNextPage() {
const { query, selectedTags, page, statusView } = component.state;
component.setState({ page: page + 1 });
component.search(query, {
page: page + 1,
tags: selectedTags,
statusView,
appendItems: true,

View file

@ -138,8 +138,14 @@ module Search
TERM_KEYS.filter_map do |term_key, search_key|
next unless @params.key? term_key
{ terms: { search_key => Array.wrap(@params[term_key]) } }
end
values = Array.wrap(@params[term_key])
if params[:tag_boolean_mode] == "all" && term_key == :tag_names
values.map { |val| { term: { search_key => val } } }
else
{ terms: { search_key => values } }
end
end.compact
end
def range_keys

View file

@ -0,0 +1,78 @@
module Search
# This class does not inherit from Search::Base like our other search classes bc it
# is using the FeedContent index to search for ReadingList reaction articles rather than its
# own separate index. The primary function of this class is to help combine and parse reaction
# data with article Elasticsearch data to populate the Reading List view.
class ReadingList
DEFAULT_PAGE = 0
DEFAULT_PER_PAGE = 60
DEFAULT_STATUS = %w[valid confirmed].freeze
# Using a class method here to follow the pattern of the other Search classes
def self.search_documents(params:, user:)
new(params: params, user: user).reading_list_reactions
end
def initialize(params:, user:)
self.status = params.delete(:status) || DEFAULT_STATUS
self.view_page = params.delete(:page) || DEFAULT_PAGE
self.view_per_page = params.delete(:per_page) || DEFAULT_PER_PAGE
self.user = user
self.search_params = params
end
def reading_list_reactions
ordered_articles = parse_and_order_articles(article_docs)
{ "reactions" => paginate_articles(ordered_articles), "total" => total }
end
private
attr_accessor :search_params, :user, :status, :view_page, :view_per_page, :total
def paginate_articles(ordered_articles)
start = view_per_page * view_page
ordered_articles[start, view_per_page] || []
end
def article_docs
return @article_docs if @article_docs
# Gather articles from Elasticsearch based on search criteria containing
# tags, text search, status, and the list of IDs of all articles in a user's
# reading list
docs = FeedContent.search_documents(
params: search_params.merge(
id: search_ids,
class_name: "Article",
page: 0,
per_page: reading_list_article_ids.count,
),
)
self.total = docs.count
@article_docs = docs.index_by { |doc| doc["id"] }
end
def reading_list_article_ids
# Collect all reading list IDs and article IDs for a user
@reading_list_article_ids ||= user.reactions.readinglist.where(status: status).order(id: :desc).pluck(
:reactable_id, :id
).to_h
end
def search_ids
reading_list_article_ids.keys.map { |id| "article_#{id}" }
end
def parse_and_order_articles(articles)
# Combines reaction and article data to create hashes that contain the fields
# the reading list view needs. Ensures articles are returned in order of reaction ID
reading_list_article_ids.map do |article_id, reaction_id|
found_article_doc = articles[article_id]
next unless found_article_doc
{ "id" => reaction_id, "user_id" => user.id, "reactable" => articles[article_id] }
end.compact
end
end
end

View file

@ -133,7 +133,7 @@ RSpec.describe "Search", type: :request, proper_status: true do
it "returns json with reactions and total" do
sign_in authorized_user
allow(Search::Reaction).to receive(:search_documents).and_return(
allow(Search::ReadingList).to receive(:search_documents).and_return(
mock_response,
)
get "/search/reactions"
@ -142,13 +142,13 @@ RSpec.describe "Search", type: :request, proper_status: true do
it "accepts array of tag names" do
sign_in authorized_user
allow(Search::Reaction).to receive(:search_documents).and_return(
allow(Search::ReadingList).to receive(:search_documents).and_return(
mock_response,
)
get "/search/reactions?tag_names[]=1&tag_names[]=2"
expect(Search::Reaction).to have_received(
expect(Search::ReadingList).to have_received(
:search_documents,
).with(params: { "tag_names" => %w[1 2], "user_id" => authorized_user.id })
).with(params: { "tag_names" => %w[1 2] }, user: authorized_user)
end
end
end

View file

@ -116,6 +116,24 @@ RSpec.describe Search::FeedContent, type: :service do
expect(doc_ids).to include(article1.id)
end
it "filters by multiple tag names when tag_boolean_mode is set to all" do
tag_one = create(:tag)
tag_two = create(:tag)
article1.tags << tag_one
article2.tags << tag_two
article2.tags << tag_one
index_documents([article1, article2])
query_params = {
tag_names: [tag_one.name, tag_two.name],
tag_boolean_mode: "all"
}
feed_docs = described_class.search_documents(params: query_params)
expect(feed_docs.count).to eq(1)
doc_ids = feed_docs.map { |t| t["id"] }
expect(doc_ids).to include(article2.id)
end
it "filters by user_id" do
index_documents([article1, article2])
query_params = { size: 5, user_id: article1.user_id }

View file

@ -0,0 +1,115 @@
require "rails_helper"
RSpec.describe Search::ReadingList, type: :service do
describe "::search_documents", elasticsearch: "FeedContent" do
let(:user) { create(:user) }
let(:article0) { create(:article) }
let(:article1) { create(:article) }
let(:article2) { create(:article) }
let(:reaction1) { create(:reaction, user: user, category: "readinglist", reactable: article1) }
let(:reaction2) { create(:reaction, user: user, category: "readinglist", reactable: article2) }
let(:reaction3) { create(:reaction, user: user, category: "readinglist", reactable: article0) }
let(:query_params) { {} }
before do
reaction1
reaction2
end
def index_documents(docs)
index_documents_for_search_class(Array.wrap(docs), Search::FeedContent)
end
it "returns fields necessary for the view" do
view_keys = %w[id reactable user_id]
reactable_keys = %w[title path readable_publish_date_string tags user]
user_keys = %w[username name profile_image_90]
index_documents(article1)
result = described_class.search_documents(params: {}, user: user)
reaction_doc = result["reactions"].first
expect(result["total"]).to be_present
expect(reaction_doc.keys).to include(*view_keys)
expect(reaction_doc["reactable"].keys).to include(*reactable_keys)
expect(reaction_doc.dig("reactable", "user").keys).to include(*user_keys)
end
context "with a query" do
it "searches by search_fields" do
allow(article1).to receive(:title).and_return("ruby")
allow(article2).to receive(:body_text).and_return("Ruby Tuesday")
index_documents([article1, article2])
query_params[:search_fields] = "ruby"
reaction_docs = described_class.search_documents(params: query_params, user: user)["reactions"]
expect(reaction_docs.count).to eq(2)
doc_ids = reaction_docs.map { |t| t["id"] }
expect(doc_ids).to include(reaction1.id, reaction2.id)
end
end
context "with a filter term" do
let(:tag_one) { create(:tag) }
let(:tag_two) { create(:tag) }
it "filters by tag names" do
article1.tags << tag_one
article2.tags << tag_two
index_documents([article1, article2])
query_params[:tag_names] = [tag_one.name]
reaction_docs = described_class.search_documents(params: query_params, user: user)["reactions"]
expect(reaction_docs.count).to eq(1)
doc_ids = reaction_docs.map { |t| t["id"] }
expect(doc_ids).to include(reaction1.id)
expect(doc_ids).not_to include(reaction2.id)
end
it "filters by multiple tag names when tag_boolean_mode is set to all" do
article1.tags << tag_one
article2.tags << tag_two
article2.tags << tag_one
index_documents([article1, article2])
query_params[:tag_names] = [tag_one.name, tag_two.name]
query_params[:tag_boolean_mode] = "all"
reaction_docs = described_class.search_documents(params: query_params, user: user)["reactions"]
expect(reaction_docs.count).to eq(1)
doc_ids = reaction_docs.map { |t| t["id"] }
expect(doc_ids).to include(reaction2.id)
expect(doc_ids).not_to include(reaction1.id)
end
it "filters by status" do
reaction1.update(status: "invalid")
index_documents([article1, article2])
query_params[:status] = %w[valid confirmed]
reaction_docs = described_class.search_documents(params: query_params, user: user)["reactions"]
expect(reaction_docs.count).to eq(1)
doc_ids = reaction_docs.map { |t| t["id"] }
expect(doc_ids).to include(reaction2.id)
end
it "only returns readinglist reactions" do
reaction1.update(category: "like")
index_documents([article1, article2])
reaction_docs = described_class.search_documents(params: query_params, user: user)["reactions"]
expect(reaction_docs.count).to eq(1)
doc_ids = reaction_docs.map { |t| t["id"] }
expect(doc_ids).to include(reaction2.id)
expect(doc_ids).not_to include(reaction1.id)
end
end
it "sorts by reaction ID DESC" do
reaction3
index_documents([article0, article1, article2])
reaction_docs = described_class.search_documents(params: query_params, user: user)["reactions"]
doc_ids = reaction_docs.map { |t| t["id"] }
expect(doc_ids).to eq([reaction3.id, reaction2.id, reaction1.id])
end
end
end