filter by instance type
This commit is contained in:
parent
3a50e5cb4d
commit
5a10c6c9e3
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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;
|
||||||
`;
|
`;
|
||||||
|
|
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;
|
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 (
|
||||||
|
|
|
@ -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";
|
||||||
|
|
|
@ -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))
|
||||||
});
|
});
|
||||||
|
|
|
@ -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"];
|
||||||
|
|
|
@ -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()));
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
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 {
|
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",
|
||||||
|
|
Loading…
Reference in a new issue