From 5ba1b69e34afea1c330c44d0d541b78e5d6ab131 Mon Sep 17 00:00:00 2001
From: Ryan Laurie <30528226+iethree@users.noreply.github.com>
Date: Mon, 26 Jun 2023 17:15:21 -0600
Subject: [PATCH] Convert SearchResult Component to Typescript (#31783)

* convert searchResult to typescript

* expand searchResult tests and convert to typescript

* remove prop types

* cleanup tests and infoText component
---
 .../metabase-enterprise/collections/utils.ts  |   2 +-
 .../src/metabase-types/api/mocks/index.ts     |   1 +
 .../src/metabase-types/api/mocks/search.ts    |  44 ++++++
 frontend/src/metabase-types/api/search.ts     |  64 ++++++++-
 .../metabase/nav/components/SearchResults.jsx |   2 +-
 frontend/src/metabase/plugins/index.ts        |   2 +-
 .../data-search/SearchResults.jsx             |   2 +-
 .../metabase/search/components/InfoText.jsx   | 125 ----------------
 .../metabase/search/components/InfoText.tsx   | 134 ++++++++++++++++++
 .../search/components/SearchResult.styled.tsx |  66 +++++----
 .../{SearchResult.jsx => SearchResult.tsx}    |  46 ++++--
 .../components/SearchResult.unit.spec.js      |  77 ----------
 .../components/SearchResult.unit.spec.tsx     | 131 +++++++++++++++++
 .../src/metabase/search/components/types.ts   |  13 ++
 .../metabase/search/containers/SearchApp.jsx  |   2 +-
 15 files changed, 463 insertions(+), 248 deletions(-)
 create mode 100644 frontend/src/metabase-types/api/mocks/search.ts
 delete mode 100644 frontend/src/metabase/search/components/InfoText.jsx
 create mode 100644 frontend/src/metabase/search/components/InfoText.tsx
 rename frontend/src/metabase/search/components/{SearchResult.jsx => SearchResult.tsx} (75%)
 delete mode 100644 frontend/src/metabase/search/components/SearchResult.unit.spec.js
 create mode 100644 frontend/src/metabase/search/components/SearchResult.unit.spec.tsx
 create mode 100644 frontend/src/metabase/search/components/types.ts

diff --git a/enterprise/frontend/src/metabase-enterprise/collections/utils.ts b/enterprise/frontend/src/metabase-enterprise/collections/utils.ts
index 6bc77550beb..6682e24296a 100644
--- a/enterprise/frontend/src/metabase-enterprise/collections/utils.ts
+++ b/enterprise/frontend/src/metabase-enterprise/collections/utils.ts
@@ -3,7 +3,7 @@ import { REGULAR_COLLECTION } from "./constants";
 
 export function isRegularCollection({
   authority_level,
-}: Bookmark | Collection) {
+}: Bookmark | Partial<Collection>) {
   // Root, personal collections don't have `authority_level`
   return !authority_level || authority_level === REGULAR_COLLECTION.type;
 }
diff --git a/frontend/src/metabase-types/api/mocks/index.ts b/frontend/src/metabase-types/api/mocks/index.ts
index c3bd4fa37ef..5a74346be19 100644
--- a/frontend/src/metabase-types/api/mocks/index.ts
+++ b/frontend/src/metabase-types/api/mocks/index.ts
@@ -15,6 +15,7 @@ export * from "./modelIndexes";
 export * from "./parameters";
 export * from "./query";
 export * from "./schema";
+export * from "./search";
 export * from "./segment";
 export * from "./series";
 export * from "./session";
diff --git a/frontend/src/metabase-types/api/mocks/search.ts b/frontend/src/metabase-types/api/mocks/search.ts
new file mode 100644
index 00000000000..b76283b76a4
--- /dev/null
+++ b/frontend/src/metabase-types/api/mocks/search.ts
@@ -0,0 +1,44 @@
+import { SearchResult, SearchScore } from "metabase-types/api";
+import { createMockCollection } from "./collection";
+
+export const createMockSearchResult = (
+  options: Partial<SearchResult> = {},
+): SearchResult => {
+  const collection = createMockCollection(options?.collection ?? undefined);
+
+  return {
+    id: 1,
+    name: "Mock search result",
+    description: "Mock search result description",
+    model: "card",
+    model_id: null,
+    archived: null,
+    collection,
+    collection_position: null,
+    table_id: 1,
+    table_name: null,
+    bookmark: null,
+    database_id: 1,
+    pk_ref: null,
+    table_schema: null,
+    collection_authority_level: null,
+    updated_at: "2023-01-01T00:00:00.000Z",
+    moderated_status: null,
+    model_name: null,
+    table_description: null,
+    initial_sync_status: null,
+    dashboard_count: null,
+    context: null,
+    scores: [createMockSearchScore()],
+    ...options,
+  };
+};
+
+export const createMockSearchScore = (
+  options: Partial<SearchScore> = {},
+): SearchScore => ({
+  score: 1,
+  weight: 1,
+  name: "text-total-occurrences",
+  ...options,
+});
diff --git a/frontend/src/metabase-types/api/search.ts b/frontend/src/metabase-types/api/search.ts
index dd942544829..0cbd5a568b1 100644
--- a/frontend/src/metabase-types/api/search.ts
+++ b/frontend/src/metabase-types/api/search.ts
@@ -1,4 +1,8 @@
+import { CardId } from "./card";
+import { Collection } from "./collection";
 import { DatabaseId } from "./database";
+import { FieldReference } from "./query";
+import { TableId } from "./table";
 
 export type SearchModelType =
   | "card"
@@ -8,7 +12,65 @@ export type SearchModelType =
   | "dataset"
   | "table"
   | "indexed-entity"
-  | "pulse";
+  | "pulse"
+  | "segment"
+  | "metric"
+  | "action";
+
+export interface SearchScore {
+  weight: number;
+  score: number;
+  name:
+    | "pinned"
+    | "bookmarked"
+    | "recency"
+    | "dashboard"
+    | "model"
+    | "official collection score"
+    | "verified"
+    | "text-consecutivity"
+    | "text-total-occurrences"
+    | "text-fullness";
+  match?: string;
+  "match-context-thunk"?: string;
+  column?: string;
+}
+
+export interface SearchResults {
+  data: SearchResult[];
+  models: SearchModelType[] | null;
+  available_models: SearchModelType[];
+  limit: number;
+  offset: number;
+  table_db_id: DatabaseId | null;
+  total: number;
+}
+
+export interface SearchResult {
+  id: number | undefined;
+  name: string;
+  model: SearchModelType;
+  description: string | null;
+  archived: boolean | null;
+  collection_position: number | null;
+  collection: Pick<Collection, "id" | "name" | "authority_level">;
+  table_id: TableId;
+  bookmark: boolean | null;
+  database_id: DatabaseId;
+  pk_ref: FieldReference | null;
+  table_schema: string | null;
+  collection_authority_level: "official" | null;
+  updated_at: string;
+  moderated_status: boolean | null;
+  model_id: CardId | null;
+  model_name: string | null;
+  table_description: string | null;
+  table_name: string | null;
+  initial_sync_status: "complete" | "incomplete" | null;
+  dashboard_count: number | null;
+  context: any; // this might be a dead property
+  scores: SearchScore[];
+}
 
 export interface SearchListQuery {
   q?: string;
diff --git a/frontend/src/metabase/nav/components/SearchResults.jsx b/frontend/src/metabase/nav/components/SearchResults.jsx
index dca27f802dc..6bc14497de2 100644
--- a/frontend/src/metabase/nav/components/SearchResults.jsx
+++ b/frontend/src/metabase/nav/components/SearchResults.jsx
@@ -7,7 +7,7 @@ import _ from "underscore";
 
 import { DEFAULT_SEARCH_LIMIT } from "metabase/lib/constants";
 import Search from "metabase/entities/search";
-import SearchResult from "metabase/search/components/SearchResult";
+import { SearchResult } from "metabase/search/components/SearchResult";
 import EmptyState from "metabase/components/EmptyState";
 import { useListKeyboardNavigation } from "metabase/hooks/use-list-keyboard-navigation";
 import { EmptyStateContainer } from "./SearchResults.styled";
diff --git a/frontend/src/metabase/plugins/index.ts b/frontend/src/metabase/plugins/index.ts
index 25b2ef9ae98..c949142c57a 100644
--- a/frontend/src/metabase/plugins/index.ts
+++ b/frontend/src/metabase/plugins/index.ts
@@ -150,7 +150,7 @@ export const PLUGIN_COLLECTIONS = {
     [JSON.stringify(AUTHORITY_LEVEL_REGULAR.type)]: AUTHORITY_LEVEL_REGULAR,
   },
   REGULAR_COLLECTION: AUTHORITY_LEVEL_REGULAR,
-  isRegularCollection: (_: Collection | Bookmark) => true,
+  isRegularCollection: (_: Partial<Collection> | Bookmark) => true,
   getAuthorityLevelMenuItems: (
     _collection: Collection,
     _onUpdate: (collection: Collection, values: Partial<Collection>) => void,
diff --git a/frontend/src/metabase/query_builder/components/DataSelector/data-search/SearchResults.jsx b/frontend/src/metabase/query_builder/components/DataSelector/data-search/SearchResults.jsx
index 04cf73a3364..bcf837801d1 100644
--- a/frontend/src/metabase/query_builder/components/DataSelector/data-search/SearchResults.jsx
+++ b/frontend/src/metabase/query_builder/components/DataSelector/data-search/SearchResults.jsx
@@ -3,7 +3,7 @@ import PropTypes from "prop-types";
 import { t } from "ttag";
 
 import { Icon } from "metabase/core/components/Icon";
-import SearchResult from "metabase/search/components/SearchResult";
+import { SearchResult } from "metabase/search/components/SearchResult";
 import { DEFAULT_SEARCH_LIMIT } from "metabase/lib/constants";
 import Search from "metabase/entities/search";
 
diff --git a/frontend/src/metabase/search/components/InfoText.jsx b/frontend/src/metabase/search/components/InfoText.jsx
deleted file mode 100644
index e1d16c0b130..00000000000
--- a/frontend/src/metabase/search/components/InfoText.jsx
+++ /dev/null
@@ -1,125 +0,0 @@
-import PropTypes from "prop-types";
-import { t, jt } from "ttag";
-
-import * as Urls from "metabase/lib/urls";
-
-import { Icon } from "metabase/core/components/Icon";
-import Link from "metabase/core/components/Link";
-
-import Schema from "metabase/entities/schemas";
-import Database from "metabase/entities/databases";
-import Table from "metabase/entities/tables";
-import { PLUGIN_COLLECTIONS } from "metabase/plugins";
-import { getTranslatedEntityName } from "metabase/nav/utils";
-import { CollectionBadge } from "./CollectionBadge";
-
-const searchResultPropTypes = {
-  database_id: PropTypes.number,
-  table_id: PropTypes.number,
-  model: PropTypes.string,
-  getCollection: PropTypes.func,
-  collection: PropTypes.object,
-  table_schema: PropTypes.string,
-};
-
-const infoTextPropTypes = {
-  result: PropTypes.shape(searchResultPropTypes),
-};
-
-export function InfoText({ result }) {
-  switch (result.model) {
-    case "card":
-      return jt`Saved question in ${formatCollection(
-        result,
-        result.getCollection(),
-      )}`;
-    case "dataset":
-      return jt`Model in ${formatCollection(result, result.getCollection())}`;
-    case "collection":
-      return getCollectionInfoText(result.collection);
-    case "database":
-      return t`Database`;
-    case "table":
-      return <TablePath result={result} />;
-    case "segment":
-      return jt`Segment of ${(<TableLink result={result} />)}`;
-    case "metric":
-      return jt`Metric for ${(<TableLink result={result} />)}`;
-    case "action":
-      return jt`for ${result.model_name}`;
-    case "indexed-entity":
-      return jt`in ${result.model_name}`;
-    default:
-      return jt`${getTranslatedEntityName(result.model)} in ${formatCollection(
-        result,
-        result.getCollection(),
-      )}`;
-  }
-}
-
-InfoText.propTypes = infoTextPropTypes;
-
-function formatCollection(result, collection) {
-  return (
-    collection.id && (
-      <CollectionBadge key={result.model} collection={collection} />
-    )
-  );
-}
-
-function getCollectionInfoText(collection) {
-  if (PLUGIN_COLLECTIONS.isRegularCollection(collection)) {
-    return t`Collection`;
-  }
-  const level = PLUGIN_COLLECTIONS.AUTHORITY_LEVEL[collection.authority_level];
-  return `${level.name} ${t`Collection`}`;
-}
-
-function TablePath({ result }) {
-  return jt`Table in ${(
-    <span key="table-path">
-      <Database.Link id={result.database_id} />{" "}
-      {result.table_schema && (
-        <Schema.ListLoader
-          query={{ dbId: result.database_id }}
-          loadingAndErrorWrapper={false}
-        >
-          {({ list }) =>
-            list?.length > 1 ? (
-              <span>
-                <Icon name="chevronright" mx="4px" size={10} />
-                {/* we have to do some {} manipulation here to make this look like the table object that browseSchema was written for originally */}
-                <Link
-                  to={Urls.browseSchema({
-                    db: { id: result.database_id },
-                    schema_name: result.table_schema,
-                  })}
-                >
-                  {result.table_schema}
-                </Link>
-              </span>
-            ) : null
-          }
-        </Schema.ListLoader>
-      )}
-    </span>
-  )}`;
-}
-
-TablePath.propTypes = {
-  result: PropTypes.shape(searchResultPropTypes),
-};
-
-function TableLink({ result }) {
-  return (
-    <Link to={Urls.tableRowsQuery(result.database_id, result.table_id)}>
-      <Table.Loader id={result.table_id} loadingAndErrorWrapper={false}>
-        {({ table }) => (table ? <span>{table.display_name}</span> : null)}
-      </Table.Loader>
-    </Link>
-  );
-}
-
-TableLink.propTypes = {
-  result: PropTypes.shape(searchResultPropTypes),
-};
diff --git a/frontend/src/metabase/search/components/InfoText.tsx b/frontend/src/metabase/search/components/InfoText.tsx
new file mode 100644
index 00000000000..b72828040a8
--- /dev/null
+++ b/frontend/src/metabase/search/components/InfoText.tsx
@@ -0,0 +1,134 @@
+import { t, jt } from "ttag";
+
+import * as Urls from "metabase/lib/urls";
+
+import { Icon } from "metabase/core/components/Icon";
+import Link from "metabase/core/components/Link";
+
+import Schema from "metabase/entities/schemas";
+import Database from "metabase/entities/databases";
+import Table from "metabase/entities/tables";
+import { PLUGIN_COLLECTIONS } from "metabase/plugins";
+import { getTranslatedEntityName } from "metabase/nav/utils";
+
+import type { Collection } from "metabase-types/api";
+import type TableType from "metabase-lib/metadata/Table";
+
+import { CollectionBadge } from "./CollectionBadge";
+import type { WrappedResult } from "./types";
+
+export function InfoText({ result }: { result: WrappedResult }) {
+  let textContent: string | string[] | JSX.Element;
+
+  switch (result.model) {
+    case "card":
+      textContent = jt`Saved question in ${formatCollection(
+        result,
+        result.getCollection(),
+      )}`;
+      break;
+    case "dataset":
+      textContent = jt`Model in ${formatCollection(
+        result,
+        result.getCollection(),
+      )}`;
+      break;
+    case "collection":
+      textContent = getCollectionInfoText(result.collection);
+      break;
+    case "database":
+      textContent = t`Database`;
+      break;
+    case "table":
+      textContent = <TablePath result={result} />;
+      break;
+    case "segment":
+      textContent = jt`Segment of ${(<TableLink result={result} />)}`;
+      break;
+    case "metric":
+      textContent = jt`Metric for ${(<TableLink result={result} />)}`;
+      break;
+    case "action":
+      textContent = jt`for ${result.model_name}`;
+      break;
+    case "indexed-entity":
+      textContent = jt`in ${result.model_name}`;
+      break;
+    default:
+      textContent = jt`${getTranslatedEntityName(
+        result.model,
+      )} in ${formatCollection(result, result.getCollection())}`;
+      break;
+  }
+
+  return <>{textContent}</>;
+}
+
+function formatCollection(
+  result: WrappedResult,
+  collection: Partial<Collection>,
+) {
+  return (
+    collection.id && (
+      <CollectionBadge key={result.model} collection={collection} />
+    )
+  );
+}
+
+function getCollectionInfoText(collection: Partial<Collection>) {
+  if (
+    PLUGIN_COLLECTIONS.isRegularCollection(collection) ||
+    !collection.authority_level
+  ) {
+    return t`Collection`;
+  }
+  const level = PLUGIN_COLLECTIONS.AUTHORITY_LEVEL[collection.authority_level];
+  return `${level.name} ${t`Collection`}`;
+}
+
+function TablePath({ result }: { result: WrappedResult }) {
+  return (
+    <>
+      {jt`Table in ${(
+        <span key="table-path">
+          <Database.Link id={result.database_id} />{" "}
+          {result.table_schema && (
+            <Schema.ListLoader
+              query={{ dbId: result.database_id }}
+              loadingAndErrorWrapper={false}
+            >
+              {({ list }: { list: typeof Schema[] }) =>
+                list?.length > 1 ? (
+                  <span>
+                    <Icon name="chevronright" size={10} />
+                    {/* we have to do some {} manipulation here to make this look like the table object that browseSchema was written for originally */}
+                    <Link
+                      to={Urls.browseSchema({
+                        db: { id: result.database_id },
+                        schema_name: result.table_schema,
+                      } as TableType)}
+                    >
+                      {result.table_schema}
+                    </Link>
+                  </span>
+                ) : null
+              }
+            </Schema.ListLoader>
+          )}
+        </span>
+      )}`}
+    </>
+  );
+}
+
+function TableLink({ result }: { result: WrappedResult }) {
+  return (
+    <Link to={Urls.tableRowsQuery(result.database_id, result.table_id)}>
+      <Table.Loader id={result.table_id} loadingAndErrorWrapper={false}>
+        {({ table }: { table: TableType }) =>
+          table ? <span>{table.display_name}</span> : null
+        }
+      </Table.Loader>
+    </Link>
+  );
+}
diff --git a/frontend/src/metabase/search/components/SearchResult.styled.tsx b/frontend/src/metabase/search/components/SearchResult.styled.tsx
index 01e99c64831..cbb5884affe 100644
--- a/frontend/src/metabase/search/components/SearchResult.styled.tsx
+++ b/frontend/src/metabase/search/components/SearchResult.styled.tsx
@@ -6,23 +6,41 @@ import Link from "metabase/core/components/Link";
 import Text from "metabase/components/type/Text";
 import LoadingSpinner from "metabase/components/LoadingSpinner";
 
-function getColorForIconWrapper(props: {
+import type { SearchModelType } from "metabase-types/api";
+
+type SearchEntity = any;
+
+interface ResultStylesProps {
+  compact: boolean;
+  active: boolean;
+  isSelected: boolean;
+}
+
+function getColorForIconWrapper({
+  item,
+  active,
+  type,
+}: {
+  item: SearchEntity;
   active: boolean;
-  type: string;
-  item: { collection_position?: unknown };
+  type: SearchModelType;
 }) {
-  if (!props.active) {
+  if (!active) {
     return color("text-medium");
-  } else if (props.item.collection_position) {
+  } else if (item.collection_position) {
     return color("saturated-yellow");
-  } else if (props.type === "collection") {
+  } else if (type === "collection") {
     return lighten("brand", 0.35);
   } else {
     return color("brand");
   }
 }
 
-export const IconWrapper = styled.div`
+export const IconWrapper = styled.div<{
+  item: SearchEntity;
+  active: boolean;
+  type: SearchModelType;
+}>`
   display: flex;
   align-items: center;
   justify-content: center;
@@ -50,13 +68,7 @@ export const Title = styled("h3")<{ active: boolean }>`
   color: ${props => color(props.active ? "text-dark" : "text-medium")};
 `;
 
-interface ResultButtonProps {
-  isSelected: boolean;
-  compact: boolean;
-  active: boolean;
-}
-
-export const ResultButton = styled.button<ResultButtonProps>`
+export const ResultButton = styled.button<ResultStylesProps>`
   ${props => resultStyles(props)}
   padding-right: 0.5rem;
   text-align: left;
@@ -68,27 +80,25 @@ export const ResultButton = styled.button<ResultButtonProps>`
   }
 `;
 
-export const ResultLink = styled(Link)<ResultButtonProps>`
+export const ResultLink = styled(Link)<ResultStylesProps>`
   ${props => resultStyles(props)}
 `;
 
-const resultStyles = (props: ResultButtonProps) => `
+const resultStyles = ({ compact, active, isSelected }: ResultStylesProps) => `
   display: block;
-  background-color: ${
-    props.isSelected ? lighten("brand", 0.63) : "transparent"
-  };
-  min-height: ${props.compact ? "36px" : "54px"};
+  background-color: ${isSelected ? lighten("brand", 0.63) : "transparent"};
+  min-height: ${compact ? "36px" : "54px"};
   padding-top: ${space(1)};
   padding-bottom: ${space(1)};
   padding-left: 14px;
-  padding-right: ${props.compact ? "20px" : space(3)};
-  cursor: ${props.active ? "pointer" : "default"};
+  padding-right: ${compact ? "20px" : space(3)};
+  cursor: ${active ? "pointer" : "default"};
 
   &:hover {
-    background-color: ${props.active ? lighten("brand", 0.63) : ""};
+    background-color: ${active ? lighten("brand", 0.63) : ""};
 
     h3 {
-      color: ${props.active || props.isSelected ? color("brand") : ""};
+      color: ${active || isSelected ? color("brand") : ""};
     }
   }
 
@@ -98,8 +108,8 @@ const resultStyles = (props: ResultButtonProps) => `
     text-decoration-style: dashed;
 
     &:hover {
-      color: ${props.active ? color("brand") : ""};
-      text-decoration-color: ${props.active ? color("brand") : ""};
+      color: ${active ? color("brand") : ""};
+      text-decoration-color: ${active ? color("brand") : ""};
     }
   }
 
@@ -111,11 +121,11 @@ const resultStyles = (props: ResultButtonProps) => `
   }
 
   h3 {
-    font-size: ${props.compact ? "14px" : "16px"};
+    font-size: ${compact ? "14px" : "16px"};
     line-height: 1.2em;
     overflow-wrap: anywhere;
     margin-bottom: 0;
-    color: ${props.active && props.isSelected ? color("brand") : ""};
+    color: ${active && isSelected ? color("brand") : ""};
   }
 
   .Icon-info {
diff --git a/frontend/src/metabase/search/components/SearchResult.jsx b/frontend/src/metabase/search/components/SearchResult.tsx
similarity index 75%
rename from frontend/src/metabase/search/components/SearchResult.jsx
rename to frontend/src/metabase/search/components/SearchResult.tsx
index f397e58a8f3..116b48f4011 100644
--- a/frontend/src/metabase/search/components/SearchResult.jsx
+++ b/frontend/src/metabase/search/components/SearchResult.tsx
@@ -1,4 +1,3 @@
-/* eslint-disable react/prop-types */
 import { color } from "metabase/lib/colors";
 import { isSyncCompleted } from "metabase/lib/syncing";
 
@@ -7,6 +6,10 @@ import Text from "metabase/components/type/Text";
 
 import { PLUGIN_COLLECTIONS, PLUGIN_MODERATION } from "metabase/plugins";
 
+import type { SearchScore, SearchModelType } from "metabase-types/api";
+
+import type { WrappedResult } from "./types";
+
 import {
   IconWrapper,
   ResultButton,
@@ -27,7 +30,7 @@ function TableIcon() {
   return <Icon name="database" />;
 }
 
-function CollectionIcon({ item }) {
+function CollectionIcon({ item }: { item: WrappedResult }) {
   const iconProps = { ...item.getIcon() };
   const isRegular = PLUGIN_COLLECTIONS.isRegularCollection(item.collection);
   if (isRegular) {
@@ -44,12 +47,24 @@ const ModelIconComponentMap = {
   collection: CollectionIcon,
 };
 
-function DefaultIcon({ item }) {
+function DefaultIcon({ item }: { item: WrappedResult }) {
   return <Icon {...item.getIcon()} size={DEFAULT_ICON_SIZE} />;
 }
 
-export function ItemIcon({ item, type, active }) {
-  const IconComponent = ModelIconComponentMap[type] || DefaultIcon;
+export function ItemIcon({
+  item,
+  type,
+  active,
+}: {
+  item: WrappedResult;
+  type: SearchModelType;
+  active: boolean;
+}) {
+  const IconComponent =
+    type in Object.keys(ModelIconComponentMap)
+      ? ModelIconComponentMap[type as keyof typeof ModelIconComponentMap]
+      : DefaultIcon;
+
   return (
     <IconWrapper item={item} type={type} active={active}>
       <IconComponent item={item} />
@@ -57,13 +72,14 @@ export function ItemIcon({ item, type, active }) {
   );
 }
 
-function Score({ scores }) {
+function Score({ scores }: { scores: SearchScore[] }) {
   return (
     <pre className="hide search-score">{JSON.stringify(scores, null, 2)}</pre>
   );
 }
 
-function Context({ context }) {
+// I think it's very likely that this is a dead codepath: RL 2023-06-21
+function Context({ context }: { context: any[] }) {
   if (!context) {
     return null;
   }
@@ -71,7 +87,7 @@ function Context({ context }) {
   return (
     <ContextContainer>
       <ContextText>
-        {context.map(({ is_match, text }, i) => {
+        {context.map(({ is_match, text }, i: number) => {
           if (!is_match) {
             return <span key={i}> {text}</span>;
           }
@@ -88,12 +104,18 @@ function Context({ context }) {
   );
 }
 
-export default function SearchResult({
+export function SearchResult({
   result,
   compact = false,
   hasDescription = true,
   onClick = undefined,
   isSelected = false,
+}: {
+  result: WrappedResult;
+  compact?: boolean;
+  hasDescription?: boolean;
+  onClick?: (result: WrappedResult) => void;
+  isSelected?: boolean;
 }) {
   const active = isItemActive(result);
   const loading = isItemLoading(result);
@@ -106,7 +128,7 @@ export default function SearchResult({
       isSelected={isSelected}
       active={active}
       compact={compact}
-      to={!onClick ? result.getUrl() : undefined}
+      to={!onClick ? result.getUrl() : ""}
       onClick={onClick ? () => onClick(result) : undefined}
       data-testid="search-result-item"
     >
@@ -137,7 +159,7 @@ export default function SearchResult({
   );
 }
 
-const isItemActive = result => {
+const isItemActive = (result: WrappedResult) => {
   switch (result.model) {
     case "table":
       return isSyncCompleted(result);
@@ -146,7 +168,7 @@ const isItemActive = result => {
   }
 };
 
-const isItemLoading = result => {
+const isItemLoading = (result: WrappedResult) => {
   switch (result.model) {
     case "database":
     case "table":
diff --git a/frontend/src/metabase/search/components/SearchResult.unit.spec.js b/frontend/src/metabase/search/components/SearchResult.unit.spec.js
deleted file mode 100644
index 8095a7aeabd..00000000000
--- a/frontend/src/metabase/search/components/SearchResult.unit.spec.js
+++ /dev/null
@@ -1,77 +0,0 @@
-import { render, screen } from "@testing-library/react";
-import { setupEnterpriseTest } from "__support__/enterprise";
-import SearchResult from "./SearchResult";
-
-function collection({
-  id = 1,
-  name = "Marketing",
-  authority_level = null,
-  getIcon = () => ({ name: "folder" }),
-  getUrl = () => `/collection/${id}`,
-  getCollection = () => {},
-} = {}) {
-  const collection = {
-    id,
-    name,
-    authority_level,
-    getIcon,
-    getUrl,
-    getCollection,
-    model: "collection",
-  };
-  collection.collection = collection;
-  return collection;
-}
-
-describe("SearchResult > Collections", () => {
-  const regularCollection = collection();
-
-  describe("OSS", () => {
-    const officialCollection = collection({
-      authority_level: "official",
-    });
-
-    it("renders regular collection correctly", () => {
-      render(<SearchResult result={regularCollection} />);
-      expect(screen.getByText(regularCollection.name)).toBeInTheDocument();
-      expect(screen.getByText("Collection")).toBeInTheDocument();
-      expect(screen.getByLabelText("folder icon")).toBeInTheDocument();
-      expect(screen.queryByLabelText("badge icon")).not.toBeInTheDocument();
-    });
-
-    it("renders official collections as regular", () => {
-      render(<SearchResult result={officialCollection} />);
-      expect(screen.getByText(regularCollection.name)).toBeInTheDocument();
-      expect(screen.getByText("Collection")).toBeInTheDocument();
-      expect(screen.getByLabelText("folder icon")).toBeInTheDocument();
-      expect(screen.queryByLabelText("badge icon")).not.toBeInTheDocument();
-    });
-  });
-
-  describe("EE", () => {
-    const officialCollection = collection({
-      authority_level: "official",
-      getIcon: () => ({ name: "badge" }),
-    });
-
-    beforeAll(() => {
-      setupEnterpriseTest();
-    });
-
-    it("renders regular collection correctly", () => {
-      render(<SearchResult result={regularCollection} />);
-      expect(screen.getByText(regularCollection.name)).toBeInTheDocument();
-      expect(screen.getByText("Collection")).toBeInTheDocument();
-      expect(screen.getByLabelText("folder icon")).toBeInTheDocument();
-      expect(screen.queryByLabelText("badge icon")).not.toBeInTheDocument();
-    });
-
-    it("renders official collections correctly", () => {
-      render(<SearchResult result={officialCollection} />);
-      expect(screen.getByText(regularCollection.name)).toBeInTheDocument();
-      expect(screen.getByText("Official Collection")).toBeInTheDocument();
-      expect(screen.getByLabelText("badge icon")).toBeInTheDocument();
-      expect(screen.queryByLabelText("folder icon")).not.toBeInTheDocument();
-    });
-  });
-});
diff --git a/frontend/src/metabase/search/components/SearchResult.unit.spec.tsx b/frontend/src/metabase/search/components/SearchResult.unit.spec.tsx
new file mode 100644
index 00000000000..e6e835e72a4
--- /dev/null
+++ b/frontend/src/metabase/search/components/SearchResult.unit.spec.tsx
@@ -0,0 +1,131 @@
+import { render, screen } from "@testing-library/react";
+import { setupEnterpriseTest } from "__support__/enterprise";
+import { createMockSearchResult } from "metabase-types/api/mocks";
+import { getIcon, queryIcon } from "__support__/ui";
+
+import type { WrappedResult } from "./types";
+import { SearchResult } from "./SearchResult";
+
+const createWrappedSearchResult = (
+  options: Partial<WrappedResult>,
+): WrappedResult => {
+  const result = createMockSearchResult(options);
+
+  return {
+    ...result,
+    getUrl: options.getUrl ?? (() => "/collection/root"),
+    getIcon: options.getIcon ?? (() => ({ name: "folder" })),
+    getCollection: options.getCollection ?? (() => result.collection),
+  };
+};
+
+describe("SearchResult", () => {
+  it("renders a search result question item", () => {
+    const result = createWrappedSearchResult({
+      name: "My Item",
+      model: "card",
+      description: "My Item Description",
+      getIcon: () => ({ name: "table" }),
+    });
+
+    render(<SearchResult result={result} />);
+
+    expect(screen.getByText(result.name)).toBeInTheDocument();
+    expect(screen.getByText(result.description as string)).toBeInTheDocument();
+    expect(getIcon("table")).toBeInTheDocument();
+  });
+
+  it("renders a search result collection item", () => {
+    const result = createWrappedSearchResult({
+      name: "My Folder of Goodies",
+      model: "collection",
+      collection: {
+        id: 1,
+        name: "This should not appear",
+        authority_level: null,
+      },
+    });
+
+    render(<SearchResult result={result} />);
+
+    expect(screen.getByText(result.name)).toBeInTheDocument();
+    expect(screen.getByText("Collection")).toBeInTheDocument();
+    expect(screen.queryByText(result.collection.name)).not.toBeInTheDocument();
+    expect(getIcon("folder")).toBeInTheDocument();
+  });
+});
+
+describe("SearchResult > Collections", () => {
+  const resultInRegularCollection = createWrappedSearchResult({
+    name: "My Regular Item",
+    collection_authority_level: null,
+    collection: {
+      id: 1,
+      name: "Regular Collection",
+      authority_level: null,
+    },
+  });
+
+  const resultInOfficalCollection = createWrappedSearchResult({
+    name: "My Official Item",
+    collection_authority_level: "official",
+    collection: {
+      id: 1,
+      name: "Official Collection",
+      authority_level: "official",
+    },
+  });
+
+  describe("OSS", () => {
+    it("renders regular collection correctly", () => {
+      render(<SearchResult result={resultInRegularCollection} />);
+      expect(
+        screen.getByText(resultInRegularCollection.name),
+      ).toBeInTheDocument();
+      expect(screen.getByText("Regular Collection")).toBeInTheDocument();
+      expect(getIcon("folder")).toBeInTheDocument();
+      expect(queryIcon("badge")).not.toBeInTheDocument();
+    });
+
+    it("renders official collections as regular", () => {
+      render(<SearchResult result={resultInOfficalCollection} />);
+      expect(
+        screen.getByText(resultInOfficalCollection.name),
+      ).toBeInTheDocument();
+      expect(screen.getByText("Official Collection")).toBeInTheDocument();
+      expect(getIcon("folder")).toBeInTheDocument();
+      expect(queryIcon("badge")).not.toBeInTheDocument();
+    });
+  });
+
+  describe("EE", () => {
+    const resultInOfficalCollectionEE: WrappedResult = {
+      ...resultInOfficalCollection,
+      getIcon: () => ({ name: "badge" }),
+    };
+
+    beforeAll(() => {
+      setupEnterpriseTest();
+    });
+
+    it("renders regular collection correctly", () => {
+      render(<SearchResult result={resultInRegularCollection} />);
+      expect(
+        screen.getByText(resultInRegularCollection.name),
+      ).toBeInTheDocument();
+      expect(screen.getByText("Regular Collection")).toBeInTheDocument();
+      expect(getIcon("folder")).toBeInTheDocument();
+      expect(queryIcon("badge")).not.toBeInTheDocument();
+    });
+
+    it("renders official collections correctly", () => {
+      render(<SearchResult result={resultInOfficalCollectionEE} />);
+      expect(
+        screen.getByText(resultInOfficalCollectionEE.name),
+      ).toBeInTheDocument();
+      expect(screen.getByText("Official Collection")).toBeInTheDocument();
+      expect(getIcon("badge")).toBeInTheDocument();
+      expect(queryIcon("folder")).not.toBeInTheDocument();
+    });
+  });
+});
diff --git a/frontend/src/metabase/search/components/types.ts b/frontend/src/metabase/search/components/types.ts
new file mode 100644
index 00000000000..4ed0da16b12
--- /dev/null
+++ b/frontend/src/metabase/search/components/types.ts
@@ -0,0 +1,13 @@
+import type { IconName } from "metabase/core/components/Icon";
+import type { SearchResult, Collection } from "metabase-types/api";
+
+export interface WrappedResult extends SearchResult {
+  getUrl: () => string;
+  getIcon: () => {
+    name: IconName;
+    size?: number;
+    width?: number;
+    height?: number;
+  };
+  getCollection: () => Partial<Collection>;
+}
diff --git a/frontend/src/metabase/search/containers/SearchApp.jsx b/frontend/src/metabase/search/containers/SearchApp.jsx
index b438f75b522..4c6737454ad 100644
--- a/frontend/src/metabase/search/containers/SearchApp.jsx
+++ b/frontend/src/metabase/search/containers/SearchApp.jsx
@@ -9,7 +9,7 @@ import Search from "metabase/entities/search";
 
 import Card from "metabase/components/Card";
 import EmptyState from "metabase/components/EmptyState";
-import SearchResult from "metabase/search/components/SearchResult";
+import { SearchResult } from "metabase/search/components/SearchResult";
 import Subhead from "metabase/components/type/Subhead";
 
 import { Icon } from "metabase/core/components/Icon";
-- 
GitLab