filter by instance type

This commit is contained in:
Tao Bojlén 2019-08-04 11:39:29 +00:00
parent 3a50e5cb4d
commit 5a10c6c9e3
14 changed files with 314 additions and 103 deletions

View file

@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- You can now click a button in the search bar to search (you can also still just prss enter, of course).
- You can now filter searches by instance type.
### Changed ### Changed
### Deprecated ### Deprecated

View file

@ -107,52 +107,15 @@ defmodule Backend.Api do
end end
end end
def search_instances(query, from \\ 0) do def search_instances(query, filters, from \\ 0) do
page_size = 50 page_size = 50
search_response = search_response =
Elasticsearch.post(Backend.Elasticsearch.Cluster, "/instances/_search", %{ Elasticsearch.post(
"sort" => "_score", Backend.Elasticsearch.Cluster,
"from" => from, "/instances/_search",
"size" => page_size, build_es_query(query, filters, page_size, from)
"min_score" => 1, )
"query" => %{
"bool" => %{
"filter" => %{
"term" => %{
"opt_out" => "false"
}
},
"should" => [
%{
"multi_match" => %{
"query" => query,
"fields" => [
"description.english",
"domain.english"
]
}
},
%{
"wildcard" => %{
"domain.keyword" => %{
"value" => query,
"boost" => 100
}
}
},
%{
"wildcard" => %{
"domain.keyword" => %{
"value" => "*#{query}*",
"boost" => 50
}
}
}
]
}
}
})
with {:ok, result} <- search_response do with {:ok, result} <- search_response do
hits = hits =
@ -172,4 +135,51 @@ defmodule Backend.Api do
} }
end end
end end
defp build_es_query(query, filters, page_size, from) do
opt_out_filter = %{"term" => %{"opt_out" => "false"}}
filters = [opt_out_filter | filters]
%{
"sort" => "_score",
"from" => from,
"size" => page_size,
# This must be >0, otherwise all documents will be returned
"min_score" => 1,
"query" => %{
"bool" => %{
"filter" => filters,
"should" => [
%{
"multi_match" => %{
"query" => query,
"fields" => [
"description.english",
"domain.english"
]
}
},
%{
# If the query exactly matches a domain, that instance should always be the first result.
"wildcard" => %{
"domain.keyword" => %{
"value" => query,
"boost" => 100
}
}
},
%{
# Give substring matches in domains a large boost, too.
"wildcard" => %{
"domain.keyword" => %{
"value" => "*#{query}*",
"boost" => 10
}
}
}
]
}
}
}
end
end end

View file

@ -7,7 +7,46 @@ defmodule BackendWeb.SearchController do
def index(conn, params) do def index(conn, params) do
query = Map.get(params, "query") query = Map.get(params, "query")
from = Map.get(params, "after", "0") |> String.to_integer() from = Map.get(params, "after", "0") |> String.to_integer()
%{hits: hits, next: next} = Api.search_instances(query, from)
# Filters
filter_keys =
params
|> Map.keys()
|> Enum.filter(fn key -> key !== "query" and key !== "after" end)
filters =
params
|> Map.take(filter_keys)
|> Map.to_list()
|> Enum.map(&convert_to_es_filter(&1))
%{hits: hits, next: next} = Api.search_instances(query, filters, from)
render(conn, "index.json", hits: hits, next: next) render(conn, "index.json", hits: hits, next: next)
end end
defp convert_to_es_filter(url_param) do
{key, value} = url_param
# Key has the form e.g. "type_eq" or "user_count_gte"
key_components = String.split(key, "_")
# The field to filter on
field = Enum.take(key_components, length(key_components) - 1) |> Enum.join("_")
# The filter relation -- one of eq, gt, gte, lt, lte
relation = Enum.take(key_components, -1)
case field do
"type" ->
%{
"term" => %{"type" => value}
}
"user_count" ->
%{
"range" => %{
"user_count" => %{
relation => value
}
}
}
end
end
end end

View file

@ -9,7 +9,7 @@ import { InstanceType } from "../atoms";
const StyledCard = styled(Card)` const StyledCard = styled(Card)`
width: 80%; width: 80%;
margin: 1em auto; margin: 0.5em auto;
background-color: #394b59 !important; background-color: #394b59 !important;
text-align: left; text-align: left;
`; `;

View file

@ -0,0 +1,68 @@
import { Button, ITagProps, Menu, MenuItem, Popover, Position, Tag } from "@blueprintjs/core";
import { IconNames } from "@blueprintjs/icons";
import React, { MouseEvent } from "react";
import styled from "styled-components";
import { INSTANCE_TYPES } from "../../constants";
import { getSearchFilterDisplayValue, ISearchFilter } from "../../searchFilters";
import { capitalize } from "../../util";
const SearchFilterContainer = styled.div`
margin: 10px 0 0 0;
`;
const TagContainer = styled.div`
display: flex;
flex-direction: row;
justify-items: flex-start;
margin-bottom: 5px;
`;
const StyledTag = styled(Tag)`
margin-left: 5px;
`;
interface ISearchFiltersProps {
selectedFilters: ISearchFilter[];
selectFilter: (filter: ISearchFilter) => void;
deselectFilter: (e: MouseEvent<HTMLButtonElement>, props: ITagProps) => void;
}
const SearchFilters: React.FC<ISearchFiltersProps> = ({ selectedFilters, selectFilter, deselectFilter }) => {
const hasInstanceTypeFilter = selectedFilters.some(sf => sf.field === "type");
const handleSelectInstanceType = (e: MouseEvent<HTMLElement>) => {
const field = "type";
const relation = "eq";
const value = e.currentTarget.innerText.toLowerCase();
const filter: ISearchFilter = {
displayValue: getSearchFilterDisplayValue(field, relation, value),
field,
relation,
value
};
selectFilter(filter);
};
const renderMenu = () => (
<Menu>
<MenuItem icon={IconNames.SYMBOL_CIRCLE} text="Instance type" disabled={hasInstanceTypeFilter}>
{INSTANCE_TYPES.map(t => (
<MenuItem key={t} text={capitalize(t)} onClick={handleSelectInstanceType} />
))}
</MenuItem>
</Menu>
);
return (
<SearchFilterContainer>
<TagContainer>
{selectedFilters.map(filter => (
<StyledTag key={filter.displayValue} minimal={true} onRemove={deselectFilter}>
{filter.displayValue}
</StyledTag>
))}
</TagContainer>
<Popover autoFocus={false} content={renderMenu()} position={Position.BOTTOM}>
<Button minimal={true} icon={IconNames.FILTER}>
{"Add filter"}
</Button>
</Popover>
</SearchFilterContainer>
);
};
export default SearchFilters;

View file

@ -16,8 +16,6 @@ const StyledCard = styled(Card)`
padding: 20px 0; padding: 20px 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: stretch;
justify-content: center;
`; `;
const SidebarContainer: React.FC = ({ children }) => { const SidebarContainer: React.FC = ({ children }) => {
return ( return (

View file

@ -1,3 +1,4 @@
export { default as Graph } from "./Graph"; export { default as Graph } from "./Graph";
export { default as Nav } from "./Nav"; export { default as Nav } from "./Nav";
export { default as SidebarContainer } from "./SidebarContainer"; export { default as SidebarContainer } from "./SidebarContainer";
export { default as SearchFilters } from "./SearchFilters";

View file

@ -1,27 +1,33 @@
import { Button, Callout, Classes, H2, Intent, NonIdealState, Spinner } from "@blueprintjs/core"; import { Button, Callout, H2, InputGroup, Intent, NonIdealState, Spinner } from "@blueprintjs/core";
import { IconNames } from "@blueprintjs/icons"; import { IconNames } from "@blueprintjs/icons";
import { push } from "connected-react-router"; import { push } from "connected-react-router";
import React from "react"; import { get, isEqual } from "lodash";
import React, { MouseEvent } from "react";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { Dispatch } from "redux"; import { Dispatch } from "redux";
import styled from "styled-components"; import styled from "styled-components";
import { setResultHover, updateSearch } from "../../redux/actions"; import { setResultHover, updateSearch } from "../../redux/actions";
import { IAppState, ISearchResultInstance } from "../../redux/types"; import { IAppState, ISearchResultInstance } from "../../redux/types";
import { ISearchFilter } from "../../searchFilters";
import { isSmallScreen } from "../../util"; import { isSmallScreen } from "../../util";
import { SearchResult } from "../molecules"; import { SearchResult } from "../molecules";
import { SearchFilters } from "../organisms";
const SearchContainer = styled.div` interface ISearchBarContainerProps {
align-self: center; hasSearchResults: boolean;
text-align: center; hasError: boolean;
width: 100%; }
`; const SearchBarContainer = styled.div<ISearchBarContainerProps>`
const SearchBarContainer = styled.div`
width: 80%; width: 80%;
margin: 0 auto;
text-align: center; text-align: center;
margin: ${props => (props.hasSearchResults || props.hasError ? "0 auto" : "auto")};
align-self: center;
`; `;
const SearchResults = styled.div` const SearchResults = styled.div`
width: 100%; width: 100%;
display: flex;
flex-direction: column;
justify-items: center;
`; `;
const StyledSpinner = styled(Spinner)` const StyledSpinner = styled(Spinner)`
margin-top: 10px; margin-top: 10px;
@ -38,17 +44,18 @@ interface ISearchScreenProps {
query: string; query: string;
hasMoreResults: boolean; hasMoreResults: boolean;
results: ISearchResultInstance[]; results: ISearchResultInstance[];
handleSearch: (query: string) => void; handleSearch: (query: string, filters: ISearchFilter[]) => void;
navigateToInstance: (domain: string) => void; navigateToInstance: (domain: string) => void;
setIsHoveringOver: (domain?: string) => void; setIsHoveringOver: (domain?: string) => void;
} }
interface ISearchScreenState { interface ISearchScreenState {
currentQuery: string; currentQuery: string;
searchFilters: ISearchFilter[];
} }
class SearchScreen extends React.PureComponent<ISearchScreenProps, ISearchScreenState> { class SearchScreen extends React.PureComponent<ISearchScreenProps, ISearchScreenState> {
public constructor(props: ISearchScreenProps) { public constructor(props: ISearchScreenProps) {
super(props); super(props);
this.state = { currentQuery: "" }; this.state = { currentQuery: "", searchFilters: [] };
} }
public componentDidMount() { public componentDidMount() {
@ -60,24 +67,19 @@ class SearchScreen extends React.PureComponent<ISearchScreenProps, ISearchScreen
public render() { public render() {
const { error, hasMoreResults, results, isLoadingResults, query } = this.props; const { error, hasMoreResults, results, isLoadingResults, query } = this.props;
let content;
if (error) { if (error) {
return <NonIdealState icon={IconNames.ERROR} title="Something went wrong." action={this.renderSearchBar()} />; content = <NonIdealState icon={IconNames.ERROR} title="Something went wrong." />;
} else if (!isLoadingResults && query && results.length === 0) { } else if (!isLoadingResults && query && results.length === 0) {
return ( content = (
<NonIdealState <NonIdealState
icon={IconNames.SEARCH} icon={IconNames.SEARCH}
title="No search results" title="No search results"
description="Try searching for something else." description="Try searching for something else."
action={this.renderSearchBar()}
/> />
); );
} } else if (!!results && results.length > 0) {
content = (
return (
<SearchContainer>
{isSmallScreen && results.length === 0 && this.renderMobileWarning()}
<H2>Find an instance</H2>
{this.renderSearchBar()}
<SearchResults> <SearchResults>
{results.map(result => ( {results.map(result => (
<SearchResult <SearchResult
@ -95,7 +97,49 @@ class SearchScreen extends React.PureComponent<ISearchScreenProps, ISearchScreen
</Button> </Button>
)} )}
</SearchResults> </SearchResults>
</SearchContainer> );
}
let rightSearchBarElement;
if (isLoadingResults) {
rightSearchBarElement = <Spinner size={Spinner.SIZE_SMALL} />;
} else if (query || error) {
rightSearchBarElement = <Button minimal={true} icon={IconNames.CROSS} onClick={this.clearQuery} />;
} else {
rightSearchBarElement = (
<Button
minimal={true}
icon={IconNames.ARROW_RIGHT}
intent={Intent.PRIMARY}
onClick={this.search}
disabled={!this.state.currentQuery}
/>
);
}
return (
<>
{isSmallScreen && results.length === 0 && this.renderMobileWarning()}
<SearchBarContainer hasSearchResults={results.length > 0} hasError={!!error}>
<H2>Find an instance</H2>
<InputGroup
leftIcon={IconNames.SEARCH}
rightElement={rightSearchBarElement}
large={true}
placeholder="Search instance names and descriptions"
type="search"
value={this.state.currentQuery}
onChange={this.handleInputChange}
onKeyPress={this.handleKeyPress}
/>
<SearchFilters
selectedFilters={this.state.searchFilters}
selectFilter={this.selectSearchFilter}
deselectFilter={this.deselectSearchFilter}
/>
</SearchBarContainer>
{content}
</>
); );
} }
@ -110,7 +154,31 @@ class SearchScreen extends React.PureComponent<ISearchScreenProps, ISearchScreen
}; };
private search = () => { private search = () => {
this.props.handleSearch(this.state.currentQuery); this.props.handleSearch(this.state.currentQuery, this.state.searchFilters);
};
private clearQuery = () => {
this.setState({ currentQuery: "" }, () => this.props.handleSearch("", []));
};
private selectSearchFilter = (filter: ISearchFilter) => {
const { searchFilters } = this.state;
// Don't add the same filters twice
if (searchFilters.some(sf => isEqual(sf, filter))) {
return;
}
this.setState({ searchFilters: [...searchFilters, filter] }, this.search);
};
private deselectSearchFilter = (e: MouseEvent<HTMLButtonElement>) => {
const { searchFilters } = this.state;
const displayValueToRemove = get(e, "currentTarget.parentElement.innerText", "");
if (!!displayValueToRemove) {
this.setState(
{ searchFilters: searchFilters.filter(sf => sf.displayValue !== displayValueToRemove) },
this.search
);
}
}; };
private selectInstanceFactory = (domain: string) => () => { private selectInstanceFactory = (domain: string) => () => {
@ -126,21 +194,6 @@ class SearchScreen extends React.PureComponent<ISearchScreenProps, ISearchScreen
this.props.setIsHoveringOver(undefined); this.props.setIsHoveringOver(undefined);
}; };
private renderSearchBar = () => (
<SearchBarContainer className={`${Classes.INPUT_GROUP} ${Classes.LARGE}`}>
<span className={`${Classes.ICON} bp3-icon-${IconNames.SEARCH}`} />
<input
className={Classes.INPUT}
type="search"
placeholder="Instance name"
dir="auto"
value={this.state.currentQuery}
onChange={this.handleInputChange}
onKeyPress={this.handleKeyPress}
/>
</SearchBarContainer>
);
private renderMobileWarning = () => ( private renderMobileWarning = () => (
<CalloutContainer> <CalloutContainer>
<Callout intent={Intent.WARNING} title="Desktop site"> <Callout intent={Intent.WARNING} title="Desktop site">
@ -159,7 +212,7 @@ const mapStateToProps = (state: IAppState) => ({
results: state.search.results results: state.search.results
}); });
const mapDispatchToProps = (dispatch: Dispatch) => ({ const mapDispatchToProps = (dispatch: Dispatch) => ({
handleSearch: (query: string) => dispatch(updateSearch(query) as any), handleSearch: (query: string, filters: ISearchFilter[]) => dispatch(updateSearch(query, filters) as any),
navigateToInstance: (domain: string) => dispatch(push(`/instance/${domain}`)), navigateToInstance: (domain: string) => dispatch(push(`/instance/${domain}`)),
setIsHoveringOver: (domain?: string) => dispatch(setResultHover(domain)) setIsHoveringOver: (domain?: string) => dispatch(setResultHover(domain))
}); });

View file

@ -38,3 +38,6 @@ export const INSTANCE_DOMAIN_PATH = "/instance/:domain";
export interface IInstanceDomainPath { export interface IInstanceDomainPath {
domain: string; domain: string;
} }
// We could also extract the values from the server response, but this would slow things down...
export const INSTANCE_TYPES = ["mastodon", "gab", "pleroma"];

View file

@ -1,6 +1,8 @@
import { isEqual } from "lodash";
import { Dispatch } from "redux"; import { Dispatch } from "redux";
import { push } from "connected-react-router"; import { push } from "connected-react-router";
import { ISearchFilter } from "../searchFilters";
import { getFromApi } from "../util"; import { getFromApi } from "../util";
import { ActionType, IAppState, IGraph, IInstanceDetails, ISearchResponse } from "./types"; import { ActionType, IAppState, IGraph, IInstanceDetails, ISearchResponse } from "./types";
@ -47,9 +49,9 @@ const graphLoadFailed = () => {
}; };
// Search // Search
const requestSearchResult = (query: string) => { const requestSearchResult = (query: string, filters: ISearchFilter[]) => {
return { return {
payload: query, payload: { query, filters },
type: ActionType.REQUEST_SEARCH_RESULTS type: ActionType.REQUEST_SEARCH_RESULTS
}; };
}; };
@ -96,7 +98,7 @@ export const loadInstance = (instanceName: string | null) => {
}; };
}; };
export const updateSearch = (query: string) => { export const updateSearch = (query: string, filters: ISearchFilter[]) => {
return (dispatch: Dispatch, getState: () => IAppState) => { return (dispatch: Dispatch, getState: () => IAppState) => {
query = query.trim(); query = query.trim();
@ -105,14 +107,23 @@ export const updateSearch = (query: string) => {
return; return;
} }
const isNewQuery = getState().search.query !== query; const prevQuery = getState().search.query;
const prevFilters = getState().search.filters;
const isNewQuery = prevQuery !== query || !isEqual(prevFilters, filters);
const next = getState().search.next; const next = getState().search.next;
let url = `search/?query=${query}`; let url = `search/?query=${query}`;
if (!isNewQuery && next) { if (!isNewQuery && next) {
url += `&after=${next}`; url += `&after=${next}`;
} }
dispatch(requestSearchResult(query));
// Add filters
// The format is e.g. type_eq=mastodon or user_count_gt=1000
filters.forEach(filter => {
url += `&${filter.field}_${filter.relation}=${filter.value}`;
});
dispatch(requestSearchResult(query, filters));
return getFromApi(url) return getFromApi(url)
.then(result => dispatch(receiveSearchResults(result))) .then(result => dispatch(receiveSearchResults(result)))
.catch(() => dispatch(searchFailed())); .catch(() => dispatch(searchFailed()));

View file

@ -1,4 +1,5 @@
import { connectRouter } from "connected-react-router"; import { connectRouter } from "connected-react-router";
import { isEqual } from "lodash";
import { combineReducers } from "redux"; import { combineReducers } from "redux";
import { History } from "history"; import { History } from "history";
@ -72,6 +73,7 @@ const currentInstance = (state = initialCurrentInstanceState, action: IAction):
const initialSearchState: ISearchState = { const initialSearchState: ISearchState = {
error: false, error: false,
filters: [],
isLoadingResults: false, isLoadingResults: false,
next: "", next: "",
query: "", query: "",
@ -80,11 +82,12 @@ const initialSearchState: ISearchState = {
const search = (state = initialSearchState, action: IAction): ISearchState => { const search = (state = initialSearchState, action: IAction): ISearchState => {
switch (action.type) { switch (action.type) {
case ActionType.REQUEST_SEARCH_RESULTS: case ActionType.REQUEST_SEARCH_RESULTS:
const query = action.payload; const { query, filters } = action.payload;
const isNewQuery = state.query !== query; const isNewQuery = state.query !== query || !isEqual(state.filters, filters);
return { return {
...state, ...state,
error: false, error: false,
filters,
isLoadingResults: true, isLoadingResults: true,
next: isNewQuery ? "" : state.next, next: isNewQuery ? "" : state.next,
query, query,
@ -99,14 +102,7 @@ const search = (state = initialSearchState, action: IAction): ISearchState => {
results: state.results.concat(action.payload.results) results: state.results.concat(action.payload.results)
}; };
case ActionType.SEARCH_RESULTS_ERROR: case ActionType.SEARCH_RESULTS_ERROR:
return { return { ...initialSearchState, error: true };
...state,
error: true,
isLoadingResults: false,
next: "",
query: "",
results: []
};
case ActionType.RESET_SEARCH: case ActionType.RESET_SEARCH:
return initialSearchState; return initialSearchState;
case ActionType.SET_SEARCH_RESULT_HOVER: case ActionType.SET_SEARCH_RESULT_HOVER:

View file

@ -1,4 +1,5 @@
import { RouterState } from "connected-react-router"; import { RouterState } from "connected-react-router";
import { ISearchFilter } from "../searchFilters";
export enum ActionType { export enum ActionType {
// Instance details // Instance details
@ -113,6 +114,7 @@ export interface ISearchState {
next: string; next: string;
query: string; query: string;
results: ISearchResultInstance[]; results: ISearchResultInstance[];
filters: ISearchFilter[];
hoveringOverResult?: string; hoveringOverResult?: string;
} }

View file

@ -0,0 +1,26 @@
type ISearchFilterRelation = "eq" | "gt" | "gte" | "lt" | "lte";
export interface ISearchFilter {
// The ES field to filter on
field: string;
relation: ISearchFilterRelation;
// The value we want to filter to
value: string;
// Human-meaningful text that we're showing in the UI
displayValue: string;
}
// Maps to translate this to user-friendly text
const searchFilterFieldTranslations = {
type: "Instance type",
user_count: "User count"
};
const searchFilterRelationTranslations = {
eq: "=",
gt: ">",
gte: ">=",
lt: "<",
lte: "<="
};
export const getSearchFilterDisplayValue = (field: string, relation: ISearchFilterRelation, value: string) =>
`${searchFilterFieldTranslations[field]} ${searchFilterRelationTranslations[relation]} ${value}`;

View file

@ -1,3 +1,5 @@
import { INSTANCE_TYPES } from "./constants";
interface IColorSchemeBase { interface IColorSchemeBase {
// The name of the coloring, e.g. "Instance type" // The name of the coloring, e.g. "Instance type"
name: string; name: string;
@ -23,8 +25,7 @@ export const typeColorScheme: IQualitativeColorScheme = {
cytoscapeDataKey: "type", cytoscapeDataKey: "type",
name: "Instance type", name: "Instance type",
type: "qualitative", type: "qualitative",
// We could also extract the values from the server response, but this would slow things down... values: INSTANCE_TYPES
values: ["mastodon", "gab", "pleroma"]
}; };
export const activityColorScheme: IQuantitativeColorScheme = { export const activityColorScheme: IQuantitativeColorScheme = {
cytoscapeDataKey: "statusesPerDay", cytoscapeDataKey: "statusesPerDay",