Merge branch 'develop' of gitlab.com:taobojlen/fediverse.space into develop
This commit is contained in:
commit
9bcec30dd6
|
@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
### 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
|
||||
|
||||
|
|
|
@ -107,52 +107,15 @@ defmodule Backend.Api do
|
|||
end
|
||||
end
|
||||
|
||||
def search_instances(query, from \\ 0) do
|
||||
def search_instances(query, filters, from \\ 0) do
|
||||
page_size = 50
|
||||
|
||||
search_response =
|
||||
Elasticsearch.post(Backend.Elasticsearch.Cluster, "/instances/_search", %{
|
||||
"sort" => "_score",
|
||||
"from" => from,
|
||||
"size" => page_size,
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
Elasticsearch.post(
|
||||
Backend.Elasticsearch.Cluster,
|
||||
"/instances/_search",
|
||||
build_es_query(query, filters, page_size, from)
|
||||
)
|
||||
|
||||
with {:ok, result} <- search_response do
|
||||
hits =
|
||||
|
@ -172,4 +135,51 @@ defmodule Backend.Api do
|
|||
}
|
||||
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
|
||||
|
|
|
@ -7,7 +7,46 @@ defmodule BackendWeb.SearchController do
|
|||
def index(conn, params) do
|
||||
query = Map.get(params, "query")
|
||||
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)
|
||||
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
|
||||
|
|
|
@ -9,7 +9,7 @@ import { InstanceType } from "../atoms";
|
|||
|
||||
const StyledCard = styled(Card)`
|
||||
width: 80%;
|
||||
margin: 1em auto;
|
||||
margin: 0.5em auto;
|
||||
background-color: #394b59 !important;
|
||||
text-align: left;
|
||||
`;
|
||||
|
|
68
frontend/src/components/organisms/SearchFilters.tsx
Normal file
68
frontend/src/components/organisms/SearchFilters.tsx
Normal 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;
|
|
@ -16,8 +16,6 @@ const StyledCard = styled(Card)`
|
|||
padding: 20px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
`;
|
||||
const SidebarContainer: React.FC = ({ children }) => {
|
||||
return (
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
export { default as Graph } from "./Graph";
|
||||
export { default as Nav } from "./Nav";
|
||||
export { default as SidebarContainer } from "./SidebarContainer";
|
||||
export { default as SearchFilters } from "./SearchFilters";
|
||||
|
|
|
@ -1,27 +1,33 @@
|
|||
import { Button, Callout, H2, InputGroup, Intent, NonIdealState, Spinner } from "@blueprintjs/core";
|
||||
import { IconNames } from "@blueprintjs/icons";
|
||||
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 { Dispatch } from "redux";
|
||||
import styled from "styled-components";
|
||||
import { setResultHover, updateSearch } from "../../redux/actions";
|
||||
import { IAppState, ISearchResultInstance } from "../../redux/types";
|
||||
import { ISearchFilter } from "../../searchFilters";
|
||||
import { isSmallScreen } from "../../util";
|
||||
import { SearchResult } from "../molecules";
|
||||
import { SearchFilters } from "../organisms";
|
||||
|
||||
const SearchContainer = styled.div`
|
||||
align-self: center;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
`;
|
||||
const SearchBarContainer = styled.div`
|
||||
interface ISearchBarContainerProps {
|
||||
hasSearchResults: boolean;
|
||||
hasError: boolean;
|
||||
}
|
||||
const SearchBarContainer = styled.div<ISearchBarContainerProps>`
|
||||
width: 80%;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
margin: ${props => (props.hasSearchResults || props.hasError ? "0 auto" : "auto")};
|
||||
align-self: center;
|
||||
`;
|
||||
const SearchResults = styled.div`
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-items: center;
|
||||
`;
|
||||
const StyledSpinner = styled(Spinner)`
|
||||
margin-top: 10px;
|
||||
|
@ -38,17 +44,18 @@ interface ISearchScreenProps {
|
|||
query: string;
|
||||
hasMoreResults: boolean;
|
||||
results: ISearchResultInstance[];
|
||||
handleSearch: (query: string) => void;
|
||||
handleSearch: (query: string, filters: ISearchFilter[]) => void;
|
||||
navigateToInstance: (domain: string) => void;
|
||||
setIsHoveringOver: (domain?: string) => void;
|
||||
}
|
||||
interface ISearchScreenState {
|
||||
currentQuery: string;
|
||||
searchFilters: ISearchFilter[];
|
||||
}
|
||||
class SearchScreen extends React.PureComponent<ISearchScreenProps, ISearchScreenState> {
|
||||
public constructor(props: ISearchScreenProps) {
|
||||
super(props);
|
||||
this.state = { currentQuery: "" };
|
||||
this.state = { currentQuery: "", searchFilters: [] };
|
||||
}
|
||||
|
||||
public componentDidMount() {
|
||||
|
@ -96,19 +103,25 @@ class SearchScreen extends React.PureComponent<ISearchScreenProps, ISearchScreen
|
|||
let rightSearchBarElement;
|
||||
if (isLoadingResults) {
|
||||
rightSearchBarElement = <Spinner size={Spinner.SIZE_SMALL} />;
|
||||
} else if (query) {
|
||||
} 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} />
|
||||
<Button
|
||||
minimal={true}
|
||||
icon={IconNames.ARROW_RIGHT}
|
||||
intent={Intent.PRIMARY}
|
||||
onClick={this.search}
|
||||
disabled={!this.state.currentQuery}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SearchContainer>
|
||||
<>
|
||||
{isSmallScreen && results.length === 0 && this.renderMobileWarning()}
|
||||
<SearchBarContainer hasSearchResults={results.length > 0} hasError={!!error}>
|
||||
<H2>Find an instance</H2>
|
||||
<SearchBarContainer>
|
||||
<InputGroup
|
||||
leftIcon={IconNames.SEARCH}
|
||||
rightElement={rightSearchBarElement}
|
||||
|
@ -119,9 +132,14 @@ class SearchScreen extends React.PureComponent<ISearchScreenProps, ISearchScreen
|
|||
onChange={this.handleInputChange}
|
||||
onKeyPress={this.handleKeyPress}
|
||||
/>
|
||||
<SearchFilters
|
||||
selectedFilters={this.state.searchFilters}
|
||||
selectFilter={this.selectSearchFilter}
|
||||
deselectFilter={this.deselectSearchFilter}
|
||||
/>
|
||||
</SearchBarContainer>
|
||||
{content}
|
||||
</SearchContainer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -136,12 +154,31 @@ class SearchScreen extends React.PureComponent<ISearchScreenProps, ISearchScreen
|
|||
};
|
||||
|
||||
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("");
|
||||
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) => () => {
|
||||
|
@ -175,7 +212,7 @@ const mapStateToProps = (state: IAppState) => ({
|
|||
results: state.search.results
|
||||
});
|
||||
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}`)),
|
||||
setIsHoveringOver: (domain?: string) => dispatch(setResultHover(domain))
|
||||
});
|
||||
|
|
|
@ -38,3 +38,6 @@ export const INSTANCE_DOMAIN_PATH = "/instance/:domain";
|
|||
export interface IInstanceDomainPath {
|
||||
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"];
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
import { isEqual } from "lodash";
|
||||
import { Dispatch } from "redux";
|
||||
|
||||
import { push } from "connected-react-router";
|
||||
import { ISearchFilter } from "../searchFilters";
|
||||
import { getFromApi } from "../util";
|
||||
import { ActionType, IAppState, IGraph, IInstanceDetails, ISearchResponse } from "./types";
|
||||
|
||||
|
@ -47,9 +49,9 @@ const graphLoadFailed = () => {
|
|||
};
|
||||
|
||||
// Search
|
||||
const requestSearchResult = (query: string) => {
|
||||
const requestSearchResult = (query: string, filters: ISearchFilter[]) => {
|
||||
return {
|
||||
payload: query,
|
||||
payload: { query, filters },
|
||||
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) => {
|
||||
query = query.trim();
|
||||
|
||||
|
@ -105,14 +107,23 @@ export const updateSearch = (query: string) => {
|
|||
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;
|
||||
let url = `search/?query=${query}`;
|
||||
if (!isNewQuery && 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)
|
||||
.then(result => dispatch(receiveSearchResults(result)))
|
||||
.catch(() => dispatch(searchFailed()));
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { connectRouter } from "connected-react-router";
|
||||
import { isEqual } from "lodash";
|
||||
import { combineReducers } from "redux";
|
||||
|
||||
import { History } from "history";
|
||||
|
@ -72,6 +73,7 @@ const currentInstance = (state = initialCurrentInstanceState, action: IAction):
|
|||
|
||||
const initialSearchState: ISearchState = {
|
||||
error: false,
|
||||
filters: [],
|
||||
isLoadingResults: false,
|
||||
next: "",
|
||||
query: "",
|
||||
|
@ -80,11 +82,12 @@ const initialSearchState: ISearchState = {
|
|||
const search = (state = initialSearchState, action: IAction): ISearchState => {
|
||||
switch (action.type) {
|
||||
case ActionType.REQUEST_SEARCH_RESULTS:
|
||||
const query = action.payload;
|
||||
const isNewQuery = state.query !== query;
|
||||
const { query, filters } = action.payload;
|
||||
const isNewQuery = state.query !== query || !isEqual(state.filters, filters);
|
||||
return {
|
||||
...state,
|
||||
error: false,
|
||||
filters,
|
||||
isLoadingResults: true,
|
||||
next: isNewQuery ? "" : state.next,
|
||||
query,
|
||||
|
@ -99,14 +102,7 @@ const search = (state = initialSearchState, action: IAction): ISearchState => {
|
|||
results: state.results.concat(action.payload.results)
|
||||
};
|
||||
case ActionType.SEARCH_RESULTS_ERROR:
|
||||
return {
|
||||
...state,
|
||||
error: true,
|
||||
isLoadingResults: false,
|
||||
next: "",
|
||||
query: "",
|
||||
results: []
|
||||
};
|
||||
return { ...initialSearchState, error: true };
|
||||
case ActionType.RESET_SEARCH:
|
||||
return initialSearchState;
|
||||
case ActionType.SET_SEARCH_RESULT_HOVER:
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { RouterState } from "connected-react-router";
|
||||
import { ISearchFilter } from "../searchFilters";
|
||||
|
||||
export enum ActionType {
|
||||
// Instance details
|
||||
|
@ -113,6 +114,7 @@ export interface ISearchState {
|
|||
next: string;
|
||||
query: string;
|
||||
results: ISearchResultInstance[];
|
||||
filters: ISearchFilter[];
|
||||
hoveringOverResult?: string;
|
||||
}
|
||||
|
||||
|
|
26
frontend/src/searchFilters.ts
Normal file
26
frontend/src/searchFilters.ts
Normal 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}`;
|
|
@ -1,3 +1,5 @@
|
|||
import { INSTANCE_TYPES } from "./constants";
|
||||
|
||||
interface IColorSchemeBase {
|
||||
// The name of the coloring, e.g. "Instance type"
|
||||
name: string;
|
||||
|
@ -23,8 +25,7 @@ export const typeColorScheme: IQualitativeColorScheme = {
|
|||
cytoscapeDataKey: "type",
|
||||
name: "Instance type",
|
||||
type: "qualitative",
|
||||
// We could also extract the values from the server response, but this would slow things down...
|
||||
values: ["mastodon", "gab", "pleroma"]
|
||||
values: INSTANCE_TYPES
|
||||
};
|
||||
export const activityColorScheme: IQuantitativeColorScheme = {
|
||||
cytoscapeDataKey: "statusesPerDay",
|
||||
|
|
Loading…
Reference in a new issue