[deploy] Execute Reading List searches in Elasticsearch (#7440)

* Execute Reading List searches in Elasticsearch

* add additional readinglist front tests
This commit is contained in:
Molly Struve 2020-04-24 09:30:17 -05:00 committed by GitHub
parent 57ae315c31
commit 4ab202ab0a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 101 additions and 233 deletions

View file

@ -5,7 +5,6 @@
//= require utilities/getImageForLink
//= require honeybadger-js/dist/honeybadger.js
//= require serviceworker-companion
//= require algolia/v3/algoliasearch.min
var instantClick
, InstantClick = instantClick = function(document, location, $userAgent) {

View file

@ -1,6 +1,6 @@
'use strict';
/* global algoliasearch, insertAfter, insertArticles, buildArticleHTML, nextPage:writable, fetching:writable, done:writable */
/* global insertAfter, insertArticles, buildArticleHTML, nextPage:writable, fetching:writable, done:writable */
var client;

View file

@ -2,7 +2,6 @@ class ReadingListItemsController < ApplicationController
def index
@reading_list_items_index = true
set_view
generate_algolia_search_key
end
def update
@ -16,18 +15,11 @@ class ReadingListItemsController < ApplicationController
private
def generate_algolia_search_key
params = { filters: "viewable_by:#{session_current_user_id}" }
@secured_algolia_key = Algolia.generate_secured_api_key(
ApplicationConfig["ALGOLIASEARCH_SEARCH_ONLY_KEY"], params
)
end
def set_view
@view = if params[:view] == "archive"
"archived"
else
"valid"
"valid,confirmed"
end
end
end

View file

@ -45,7 +45,6 @@ module.exports = {
InstantClick: false,
filterXSS: false,
Pusher: false,
algoliasearch: false,
ga: false,
Honeybadger: false,
AndroidBridge: false,

View file

@ -1,62 +0,0 @@
global.document.head.innerHTML =
"<meta name='algolia-public-id' content='abc123' />" +
"<meta name='algolia-public-key' content='abc123' />" +
"<meta name='environment' content='test' />";
const mockIndex = {
search: (query, _options) =>
new Promise((resolve, _reject) => {
process.nextTick(() => {
const searchResults = {
gi: {
hits: [
{
name: 'git',
bg_color_hex: '#888751',
text_color_hex: '#56c938',
hotness_score: 0,
supported: true,
objectID: '4',
_highlightResult: {
name: {
value: '<em>gi</em>t',
matchLevel: 'full',
fullyHighlighted: false,
matchedWords: ['gi'],
},
bg_color_hex: {
value: '#888751',
matchLevel: 'none',
matchedWords: [],
},
text_color_hex: {
value: '#56c938',
matchLevel: 'none',
matchedWords: [],
},
},
},
],
nbHits: 1,
page: 0,
nbPages: 1,
hitsPerPage: 10,
processingTimeMS: 1,
exhaustiveNbHits: true,
query: 'gi',
params:
'query=gi&hitsPerPage=10&filters=supported%3Atrue&restrictIndices=searchables_development%2CTag_development%2Cordered_articles_development%2Cordered_articles_by_published_at_development%2Cordered_articles_by_positive_reactions_count_development',
},
};
const results = searchResults[query] || { hits: [] };
resolve(results);
});
}),
};
const client = {
initIndex: _index => mockIndex,
};
export default jest.fn().mockImplementation((_id, _key) => client);

View file

@ -16,7 +16,7 @@ import { ItemListItemArchiveButton } from '../src/components/ItemList/ItemListIt
import { ItemListLoadMoreButton } from '../src/components/ItemList/ItemListLoadMoreButton';
import { ItemListTags } from '../src/components/ItemList/ItemListTags';
const STATUS_VIEW_VALID = 'valid';
const STATUS_VIEW_VALID = 'valid,confirmed';
const STATUS_VIEW_ARCHIVED = 'archived';
const READING_LIST_ARCHIVE_PATH = '/readinglist/archive';
const READING_LIST_PATH = '/readinglist';
@ -50,19 +50,14 @@ export class ReadingList extends Component {
}
componentDidMount() {
const { hitsPerPage, statusView } = this.state;
const { statusView } = this.state;
this.performInitialSearch({
containerId: 'reading-list',
indexName: 'SecuredReactions',
searchOptions: {
hitsPerPage,
filters: `status:${statusView}`,
},
searchOptions: { status: `${statusView}` },
});
}
toggleStatusView = event => {
toggleStatusView = (event) => {
event.preventDefault();
const { query, selectedTags } = this.state;
@ -174,12 +169,12 @@ export class ReadingList extends Component {
const isStatusViewValid = this.statusViewValid();
const archiveButtonLabel = isStatusViewValid ? 'archive' : 'unarchive';
const itemsToRender = items.map(item => {
const itemsToRender = items.map((item) => {
return (
<ItemListItem item={item}>
<ItemListItemArchiveButton
text={archiveButtonLabel}
onClick={e => this.toggleArchiveStatus(e, item)}
onClick={(e) => this.toggleArchiveStatus(e, item)}
/>
</ItemListItem>
);
@ -226,7 +221,7 @@ export class ReadingList extends Component {
<div className="status-view-toggle">
<a
href={READING_LIST_ARCHIVE_PATH}
onClick={e => this.toggleStatusView(e)}
onClick={(e) => this.toggleStatusView(e)}
data-no-instant
>
{isStatusViewValid ? 'View Archive' : 'View Reading List'}

View file

@ -1,5 +1,5 @@
// Shared behavior between the reading list and history pages
import setupAlgoliaIndex from '../src/utils/algolia';
import { fetchSearch } from '../src/utils/search';
// Provides the initial state for the component
export function defaultState(options) {
@ -59,25 +59,24 @@ export function clearSelectedTags(event) {
}
// Perform the initial search
export function performInitialSearch({
containerId,
indexName,
searchOptions = {},
}) {
export function performInitialSearch({ searchOptions = {} }) {
const component = this;
const { hitsPerPage } = component.state;
const dataHash = { page: 0, per_page: hitsPerPage };
const index = setupAlgoliaIndex({ containerId, indexName });
if (searchOptions.status) {
dataHash.status = searchOptions.status.split(',');
}
index.search('', searchOptions).then(result => {
const responsePromise = fetchSearch('reactions', dataHash);
return responsePromise.then((response) => {
const reactions = response.result;
component.setState({
items: result.hits,
totalCount: result.nbHits,
index, // set the index in the component state, to be retrieved later
page: 0,
items: reactions,
itemsLoaded: true,
// show the button if the number of total results is greater
// than the number of results for the current page
showLoadMoreButton: result.nbHits > hitsPerPage,
totalCount: response.total,
showLoadMoreButton: hitsPerPage < response.total,
});
});
}
@ -90,28 +89,31 @@ export function search(query, { page, tags, statusView }) {
// we check `undefined` because page can be 0
const newPage = page === undefined ? component.state.page : page;
const { index, hitsPerPage, items } = component.state;
const { hitsPerPage } = component.state;
const dataHash = {
search_fields: query,
page: newPage,
per_page: hitsPerPage,
};
const filters = { hitsPerPage, page: newPage };
if (tags && tags.length > 0) {
filters.tagFilters = tags;
dataHash.tag_names = tags;
dataHash.tag_boolean_mode = 'all';
}
if (statusView) {
filters.filters = `status:${statusView}`;
dataHash.status = statusView.split(',');
}
index.search(query, filters).then(result => {
// append new items at the end
const allItems =
page === undefined ? result.hits : [...items, ...result.hits];
const responsePromise = fetchSearch('reactions', dataHash);
return responsePromise.then((response) => {
const reactions = response.result;
component.setState({
query,
page: newPage,
items: result.hits,
totalCount: allItems.length,
// show the button if the number of items is lower than the number
// of available results
showLoadMoreButton: allItems.length < result.nbHits,
items: reactions,
totalCount: response.total,
showLoadMoreButton: reactions.length < response.total,
});
});
}

View file

@ -4,13 +4,12 @@ import { PropTypes } from 'preact-compat';
export const ItemListItem = ({ item, children }) => {
const adaptedItem = {
path: item.article_path || item.searchable_reactable_path,
title: item.article_title || item.searchable_reactable_title,
user: item.article_user || item.reactable_user,
publishedDate: item.reactable_published_date,
visitedDate: item.readable_visited_at,
readingTime: item.article_reading_time || item.reading_time,
tags: item.article_tags || item.reactable_tags,
path: item.reactable.path,
title: item.reactable.title,
user: item.reactable.user,
publishedDate: item.reactable.published_date_string,
readingTime: item.reactable.reading_time,
tags: item.reactable.tags,
};
// update readingTime to 1 min if the reading time is less than 1 min
@ -24,17 +23,15 @@ export const ItemListItem = ({ item, children }) => {
<a className="item-user" href={`/${adaptedItem.user.username}`}>
<img src={adaptedItem.user.profile_image_90} alt="Profile Pic" />
{`${adaptedItem.user.name}`}
{adaptedItem.visitedDate
? `visited on ${adaptedItem.visitedDate}`
: `${adaptedItem.publishedDate}`}
{`${adaptedItem.publishedDate}`}
{`${adaptedItem.readingTime} min read・`}
</a>
{adaptedItem.tags.length > 0 ? (
<span className="item-tags">
{adaptedItem.tags.map(tag => (
<a className="item-tag" href={`/t/${tag}`}>
{`#${tag}`}
{adaptedItem.tags.map((tag) => (
<a className="item-tag" href={`/t/${tag.name}`}>
{`#${tag.name}`}
</a>
))}
</span>
@ -49,30 +46,19 @@ export const ItemListItem = ({ item, children }) => {
);
};
const historyItemPropTypes = PropTypes.shape({
article_path: PropTypes.string.isRequired,
article_title: PropTypes.string.isRequired,
article_user: PropTypes.shape({
username: PropTypes.string.isRequired,
profile_image_90: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
}),
article_reading_time: PropTypes.number.isRequired,
readable_visited_at: PropTypes.string.isRequired,
article_tags: PropTypes.arrayOf(PropTypes.string).isRequired,
});
const readingListItemPropTypes = PropTypes.shape({
searchable_reactable_path: PropTypes.string.isRequired,
searchable_reactable_title: PropTypes.string.isRequired,
reactable_user: PropTypes.shape({
username: PropTypes.string.isRequired,
profile_image_90: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
}),
reading_time: PropTypes.number.isRequired,
reactable_published_date: PropTypes.string.isRequired,
reactable_tags: PropTypes.arrayOf(PropTypes.string).isRequired,
reactable: {
path: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
reading_time: PropTypes.number.isRequired,
published_date_string: PropTypes.string.isRequired,
user: PropTypes.shape({
username: PropTypes.string.isRequired,
profile_image_90: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
}),
tags: PropTypes.arrayOf(PropTypes.string).isRequired,
},
});
ItemListItem.defaultProps = {
@ -80,7 +66,6 @@ ItemListItem.defaultProps = {
};
ItemListItem.propTypes = {
item: PropTypes.oneOfType([historyItemPropTypes, readingListItemPropTypes])
.isRequired,
item: PropTypes.oneOfType([readingListItemPropTypes]).isRequired,
children: PropTypes.element,
};

View file

@ -3,55 +3,61 @@ import render from 'preact-render-to-json';
import { shallow } from 'preact-render-spy';
import { ItemListItem } from '../ItemListItem';
const historyItem = {
article_path: '/article',
article_title: 'Title',
article_user: {
username: 'bob',
profile_image_90: 'https://dummyimage.com/90x90',
name: 'Bob',
},
article_reading_time: 1,
readable_visited_at: 'Jun 29',
article_tags: ['discuss'],
};
const item = {
searchable_reactable_path: '/article',
searchable_reactable_title: 'Title',
reactable_user: {
username: 'bob',
profile_image_90: 'https://dummyimage.com/90x90',
name: 'Bob',
reactable: {
path: '/article',
title: 'Title',
published_date_string: 'Jun 29',
reading_time: 1,
user: {
username: 'bob',
profile_image_90: 'https://dummyimage.com/90x90',
name: 'Bob',
},
tags: [{ name: 'discuss' }],
},
reading_time: 1,
reactable_published_date: 'Jun 29',
reactable_tags: ['discuss'],
};
describe('<ItemListItem />', () => {
it('renders properly with a history item', () => {
const tree = render(<ItemListItem item={historyItem} />);
expect(tree).toMatchSnapshot();
});
it('renders properly with a readinglist item', () => {
const tree = render(<ItemListItem item={item} />);
expect(tree).toMatchSnapshot();
});
it('renders with readingtime of 1 min if reading time is less than 1 min.', () => {
const wrapper = shallow(<ItemListItem item={{...item, reading_time: 0.5 }} />);
item.reactable.reading_time = 0.5;
const wrapper = shallow(<ItemListItem item={item} />);
expect(wrapper.find('.item-user').text()).toContain('1 min read');
});
it('renders with readingtime of 1 min if reading time is null.', () => {
const wrapper = shallow(<ItemListItem item={{...item, reading_time: null }} />);
item.reactable.reading_time = null;
const wrapper = shallow(<ItemListItem item={item} />);
expect(wrapper.find('.item-user').text()).toContain('1 min read');
});
it('renders correct readingtime.', () => {
item.reactable.reading_time = 10;
const wrapper = shallow(<ItemListItem item={item} />);
expect(wrapper.find('.item-user').text()).toContain('10 min read');
});
it('renders without any tags if the tags array is empty.', () => {
const wrapper = shallow(<ItemListItem item={{...item, reactable_tags: [] }} />);
expect(wrapper.find('.item-user').text()).toContain('1 min read');
item.reactable.tags = [];
const wrapper = shallow(<ItemListItem item={item} />);
expect(wrapper.find('.item-tags').exists()).toEqual(false);
});
it('renders tags with links if present.', () => {
item.reactable.tags = [{ name: 'discuss' }];
const wrapper = shallow(<ItemListItem item={item} />);
expect(wrapper.find('.item-tag')[0].attributes.href).toEqual('/t/discuss');
expect(wrapper.find('.item-tag').text()).toContain('discuss');
});
it('renders user information', () => {
const wrapper = shallow(<ItemListItem item={item} />);
expect(wrapper.find('.item-user')[0].attributes.href).toEqual('/bob');
expect(wrapper.find('.item-user').text()).toContain('Bob');
});
});

View file

@ -1,46 +1,5 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<ItemListItem /> renders properly with a history item 1`] = `
<div
class="item-wrapper"
>
<a
class="item"
href="/article"
>
<div
class="item-title"
>
Title
</div>
<div
class="item-details"
>
<a
class="item-user"
href="/bob"
>
<img
alt="Profile Pic"
src="https://dummyimage.com/90x90"
/>
Bob・visited on Jun 29・1 min read・
</a>
<span
class="item-tags"
>
<a
class="item-tag"
href="/t/discuss"
>
#discuss
</a>
</span>
</div>
</a>
</div>
`;
exports[`<ItemListItem /> renders properly with a readinglist item 1`] = `
<div
class="item-wrapper"

View file

@ -1,7 +0,0 @@
export default function setupAlgoliaIndex({ containerId, indexName }) {
const id = document.querySelector("meta[name='algolia-public-id']").content;
const key = document.getElementById(containerId).dataset.algoliaKey;
const env = document.querySelector("meta[name='environment']").content;
const client = algoliasearch(id, key);
return client.initIndex(`${indexName}_${env}`);
}

View file

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

View file

@ -92,7 +92,7 @@ RSpec.describe Search::Reaction, type: :service do
it "filters by status" do
reaction1.update(status: "invalid")
index_documents([reaction1, reaction2])
query_params[:status] = ["valid"]
query_params[:status] = %w[valid confirmed]
reaction_docs = described_class.search_documents(params: query_params)["reactions"]
expect(reaction_docs.count).to eq(1)