diff --git a/CHANGELOG.md b/CHANGELOG.md index c13b70a..5e1c9b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/backend/lib/backend/api.ex b/backend/lib/backend/api.ex index 8b3d017..7ba78b3 100644 --- a/backend/lib/backend/api.ex +++ b/backend/lib/backend/api.ex @@ -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 diff --git a/backend/lib/backend_web/controllers/search_controller.ex b/backend/lib/backend_web/controllers/search_controller.ex index 82a9d3a..89b6fbf 100644 --- a/backend/lib/backend_web/controllers/search_controller.ex +++ b/backend/lib/backend_web/controllers/search_controller.ex @@ -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 diff --git a/frontend/src/components/molecules/SearchResult.tsx b/frontend/src/components/molecules/SearchResult.tsx index 002f308..739e8c8 100644 --- a/frontend/src/components/molecules/SearchResult.tsx +++ b/frontend/src/components/molecules/SearchResult.tsx @@ -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; `; diff --git a/frontend/src/components/organisms/SearchFilters.tsx b/frontend/src/components/organisms/SearchFilters.tsx new file mode 100644 index 0000000..eaf660a --- /dev/null +++ b/frontend/src/components/organisms/SearchFilters.tsx @@ -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, props: ITagProps) => void; +} +const SearchFilters: React.FC = ({ selectedFilters, selectFilter, deselectFilter }) => { + const hasInstanceTypeFilter = selectedFilters.some(sf => sf.field === "type"); + + const handleSelectInstanceType = (e: MouseEvent) => { + 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 = () => ( + + + {INSTANCE_TYPES.map(t => ( + + ))} + + + ); + return ( + + + {selectedFilters.map(filter => ( + + {filter.displayValue} + + ))} + + + + + + ); +}; +export default SearchFilters; diff --git a/frontend/src/components/organisms/SidebarContainer.tsx b/frontend/src/components/organisms/SidebarContainer.tsx index 7e390de..6af77a4 100644 --- a/frontend/src/components/organisms/SidebarContainer.tsx +++ b/frontend/src/components/organisms/SidebarContainer.tsx @@ -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 ( diff --git a/frontend/src/components/organisms/index.ts b/frontend/src/components/organisms/index.ts index c9013e3..6878bd7 100644 --- a/frontend/src/components/organisms/index.ts +++ b/frontend/src/components/organisms/index.ts @@ -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"; diff --git a/frontend/src/components/screens/SearchScreen.tsx b/frontend/src/components/screens/SearchScreen.tsx index 6addd17..61ff743 100644 --- a/frontend/src/components/screens/SearchScreen.tsx +++ b/frontend/src/components/screens/SearchScreen.tsx @@ -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` 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 { public constructor(props: ISearchScreenProps) { super(props); - this.state = { currentQuery: "" }; + this.state = { currentQuery: "", searchFilters: [] }; } public componentDidMount() { @@ -96,19 +103,25 @@ class SearchScreen extends React.PureComponent; - } else if (query) { + } else if (query || error) { rightSearchBarElement =