Skip to content

Commit

Permalink
Use SearchResult component
Browse files Browse the repository at this point in the history
Update the backend to return much richer match data

[Fixes #14832]
  • Loading branch information
tsmacdonald committed Feb 18, 2021
1 parent 4fba5cf commit 32d0bfc
Show file tree
Hide file tree
Showing 4 changed files with 79 additions and 41 deletions.
19 changes: 2 additions & 17 deletions frontend/src/metabase/home/containers/SearchApp.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import Database from "metabase/entities/databases";

import Card from "metabase/components/Card";
import EmptyState from "metabase/components/EmptyState";
import EntityItem from "metabase/components/EntityItem";
import SearchResult from "metabase/search/components/SearchResult";
import Subhead from "metabase/components/type/Subhead";
import { FILTERS } from "metabase/collections/components/ItemTypeFilterBar";

Expand Down Expand Up @@ -180,22 +180,7 @@ const SearchResultSection = ({ title, items }) => (
break;
}

return (
<Link
to={item.getUrl()}
key={item.id}
data-metabase-event={`Search Results;Item Click;${item.model}`}
>
<EntityItem
variant="list"
name={item.getName()}
iconName={item.getIcon()}
iconColor={item.getColor()}
item={item}
extraInfo={extraInfo}
/>
</Link>
);
return <SearchResult result={item} />;
})}
</Card>
);
36 changes: 31 additions & 5 deletions frontend/src/metabase/search/components/SearchResult.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ export default function SearchResult({ result }) {
return <DashboardResult dashboard={result} />;
case "collection":
return <CollectionResult collection={result} />;
case "table":
return <TableResult table={result}/>;
default:
return <div>{result.name}</div>;
}
Expand Down Expand Up @@ -108,14 +110,25 @@ function CollectionResult({ collection }) {
<Flex align="center">
<ItemIcon item={collection} />
<Title>{collection.name}</Title>
<pre>{collection.score}</pre>
</Flex>
</ResultLink>
);
}

function formattedContext(context) {
return context.map(function({ is_match, text }) {
if (is_match) {
return <strong> {text}</strong>
} else {
return <span> {text}</span>
}
});
}

function QuestionResult({ question }) {
return (
<ResultLink to={Urls.question(question.id)}>
<ResultLink to={question.getUrl()}>
<Flex align="center">
<ItemIcon item={question} />
<Box>
Expand All @@ -132,16 +145,17 @@ function QuestionResult({ question }) {
</Flex>
{question.context && (
<Box ml="42px" mt="12px">
<strong>{question.context.match}:</strong> {question.context.content}
{formattedContext(question.context)}
</Box>
)}
<pre>{question.score}</pre>
</ResultLink>
);
}

function DashboardResult({ dashboard }) {
return (
<ResultLink>
<ResultLink to={dashboard.getUrl()}>
<Flex align="center">
<ItemIcon item={dashboard} />
<Box>
Expand All @@ -151,10 +165,22 @@ function DashboardResult({ dashboard }) {
</Flex>
{dashboard.context && (
<Box ml="42px" mt="12px">
<strong>{dashboard.context.match}:</strong>{" "}
{dashboard.context.content}
{formattedContext(dashboard.context)}
</Box>
)}
<pre>{dashboard.score}</pre>
</ResultLink>
);
}

function TableResult({ table }) {
return (
<ResultLink to={table.getUrl()}>
<Flex align="center">
<ItemIcon item={table} />
<Title>{table.name}</Title>
<pre>{table.score}</pre>
</Flex>
</ResultLink>
);
}
58 changes: 42 additions & 16 deletions src/metabase/search/scoring.clj
Original file line number Diff line number Diff line change
Expand Up @@ -59,18 +59,34 @@
(count search-tokens)))
fs))

(defn- match-context
"Breaks the matched-text into match/no-match chunks and returns a seq of them in order. Each chunk is a map with keys
`is_match` (true/false) and `text`"
[query-tokens match-tokens]
(->> match-tokens
(map (fn [match-token]
{:text match-token
:is_match (boolean (some #(matches? % match-token) query-tokens))}))
(partition-by :is_match)
(map (fn [matches-or-misses-map]
{:is_match (:is_match (first matches-or-misses-map))
:text (str/join " "
(map :text matches-or-misses-map))}))))

(defn- score-with
[scoring-fns tokens result]
(let [scores (for [column (search-config/searchable-columns-for-model (search-config/model-name->class (:model result)))
:let [target (-> result
(get column)
(search-config/column->string (:model result) column))
score (reduce + (score-ratios tokens
(-> target normalize tokenize)
scoring-fns))]
:when (> score 0)]
[scoring-fns query-tokens search-result]
(let [scores (for [column (search-config/searchable-columns-for-model (search-config/model-name->class (:model search-result)))
:let [matched-text (-> search-result
(get column)
(search-config/column->string (:model search-result) column))
match-tokens (-> matched-text normalize tokenize)
score (reduce + (score-ratios query-tokens
match-tokens
scoring-fns))]
:when (> score 0)]
{:score score
:match target
:match matched-text
:match-context (match-context query-tokens match-tokens)
:column column})]
(when (seq scores)
(apply max-key :score scores))))
Expand Down Expand Up @@ -117,6 +133,18 @@
[[score-1 _result-1] [score-2 _result-2]]
(compare score-1 score-2))

(defn- serialize
"Massage the raw result from the DB into something more useful for the client"
[{:keys [collection_id collection_name name display_name] :as row} {:keys [score match column match-context] :as hit}]
(-> row
(assoc
:name (if (or (= column :name) (nil? display_name)) name display_name)
:matched_column column
:matched_text match
:context (when-not (#{:name :display_name :collection_name} column) match-context) ;; TODO pull this out somewhere more responsible
:score score
:collection {:id collection_id :name collection_name})))

(defn accumulate-top-results
"Accumulator that saves the top n (defined by `search-config/max-filtered-results`) sent to it"
([] (PriorityQueue. search-config/max-filtered-results compare-score-and-result))
Expand All @@ -140,11 +168,9 @@
"Returns a pair of [score, result] or nil. The score is a vector of comparable things in priority order. The result
has `:matched_column` and `matched_text` injected in"
[query-string result]
(let [{:keys [score column match] :as hit} (score-with-match query-string result)]
(let [hit (score-with-match query-string result)]
(and hit
[[(- score)
[[(- (:score hit))
(model->sort-position (:model result))
(:name result)]
(assoc result
:matched_column column
:matched_text match)])))
(:name result)],
(serialize result hit)])))
7 changes: 4 additions & 3 deletions test/metabase/api/search_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@
mt/id)

(defn- search-request [user-kwd & params]
(let [raw-results (apply (mt/user->client user-kwd) :get 200 "search" params)
(let [raw-results (apply (partial mt/user-http-request user-kwd) :get 200 "search" params)
keep-database-id (if (fn? *search-request-results-database-id*)
(*search-request-results-database-id*)
*search-request-results-database-id*)]
Expand All @@ -129,7 +129,8 @@
:when (contains? #{keep-database-id nil} (:database_id result))]
(-> result
mt/boolean-ids-and-timestamps
(update :collection_name #(some-> % string?)))))))))
(update :collection_name #(some-> % string?))
(dissoc :score :context :collection))))))))

(deftest basic-test
(testing "Basic search, should find 1 of each entity type, all items in the root collection"
Expand Down Expand Up @@ -332,7 +333,7 @@
(let [lancelot "Lancelot's Favorite Furniture"]
(mt/with-temp Table [table {:name "Round Table" :display_name lancelot}]
(do-test-users [user [:crowberto :rasta]]
(is (= [(assoc (default-table-search-row "Round Table") :display_name lancelot :matched_text lancelot)]
(is (= [(assoc (default-table-search-row "Round Table") :display_name lancelot :matched_text lancelot :name lancelot)]
(search-request user :q "Lancelot")))))))
(testing "When searching with ?archived=true, normal Tables should not show up in the results"
(let [table-name (mt/random-name)]
Expand Down

0 comments on commit 32d0bfc

Please sign in to comment.