From afd64fdcbb2e525f33e8925589f83a49fd7e8aeb Mon Sep 17 00:00:00 2001
From: Oisin Coveney <oisin@metabase.com>
Date: Wed, 9 Aug 2023 21:12:45 +0300
Subject: [PATCH] Search Filter Milestone 1 - Type Filters for Search (#32742)

---
 .../embedding/embedding-full-app.cy.spec.js   |   2 +-
 .../onboarding/search/search.cy.spec.js       | 261 +++++++++++++++++-
 .../scenarios/question/notebook.cy.spec.js    |   4 +-
 .../LoadingSpinner/LoadingSpinner.tsx         |  13 +-
 .../nav/components/SearchBar.styled.tsx       |  11 +
 .../src/metabase/nav/components/SearchBar.tsx |  94 +++++--
 .../nav/components/SearchBar.unit.spec.tsx    | 181 ++++++++++++
 .../metabase/nav/components/SearchResults.jsx |  15 +-
 .../src/metabase/nav/utils/model-names.ts     |  10 +-
 .../components/view/QuestionFilters.jsx       |   1 +
 .../metabase/search/components/InfoText.tsx   |   2 +-
 .../SearchFilterModal.styled.tsx              |  10 +
 .../SearchFilterModal/SearchFilterModal.tsx   | 100 +++++++
 .../SearchFilterModal.unit.spec.tsx           | 125 +++++++++
 .../SearchFilterModalFooter.styled.tsx        |  13 +
 .../SearchFilterModalFooter.tsx               |  28 ++
 .../filters/SearchFilterView.tsx              |  35 +++
 .../filters/SearchFilterView.unit.spec.tsx    |  61 ++++
 .../filters/TypeFilter.styled.tsx             |   7 +
 .../SearchFilterModal/filters/TypeFilter.tsx  |  51 ++++
 .../filters/TypeFilter.unit.spec.tsx          | 155 +++++++++++
 .../search/components/SearchResult.tsx        |  15 +-
 .../components/SearchResult.unit.spec.tsx     |   4 +-
 .../TypeSearchSidebar.styled.tsx              |   9 +
 .../TypeSearchSidebar/TypeSearchSidebar.tsx   |  49 ++++
 .../TypeSearchSidebar.unit.spec.tsx           | 119 ++++++++
 .../components/TypeSearchSidebar/index.ts     |   2 +
 .../src/metabase/search/components/types.ts   |  13 -
 frontend/src/metabase/search/constants.ts     |  61 ++++
 .../metabase/search/containers/SearchApp.jsx  | 242 ++++++----------
 .../search/containers/SearchApp.styled.tsx    |   2 +-
 .../search/containers/SearchApp.unit.spec.tsx | 216 +++++++++++++++
 .../metabase/search/containers/constants.ts   |   1 +
 frontend/src/metabase/search/types.ts         |  36 +++
 frontend/src/metabase/search/utils/index.ts   |   1 +
 .../search/utils/search-location/index.ts     |   1 +
 .../utils/search-location/search-location.ts  |  27 ++
 .../search-location.unit.spec.ts              |  87 ++++++
 .../test/__support__/server-mocks/search.ts   |  27 +-
 39 files changed, 1847 insertions(+), 244 deletions(-)
 create mode 100644 frontend/src/metabase/nav/components/SearchBar.unit.spec.tsx
 create mode 100644 frontend/src/metabase/search/components/SearchFilterModal/SearchFilterModal.styled.tsx
 create mode 100644 frontend/src/metabase/search/components/SearchFilterModal/SearchFilterModal.tsx
 create mode 100644 frontend/src/metabase/search/components/SearchFilterModal/SearchFilterModal.unit.spec.tsx
 create mode 100644 frontend/src/metabase/search/components/SearchFilterModal/SearchFilterModalFooter.styled.tsx
 create mode 100644 frontend/src/metabase/search/components/SearchFilterModal/SearchFilterModalFooter.tsx
 create mode 100644 frontend/src/metabase/search/components/SearchFilterModal/filters/SearchFilterView.tsx
 create mode 100644 frontend/src/metabase/search/components/SearchFilterModal/filters/SearchFilterView.unit.spec.tsx
 create mode 100644 frontend/src/metabase/search/components/SearchFilterModal/filters/TypeFilter.styled.tsx
 create mode 100644 frontend/src/metabase/search/components/SearchFilterModal/filters/TypeFilter.tsx
 create mode 100644 frontend/src/metabase/search/components/SearchFilterModal/filters/TypeFilter.unit.spec.tsx
 create mode 100644 frontend/src/metabase/search/components/TypeSearchSidebar/TypeSearchSidebar.styled.tsx
 create mode 100644 frontend/src/metabase/search/components/TypeSearchSidebar/TypeSearchSidebar.tsx
 create mode 100644 frontend/src/metabase/search/components/TypeSearchSidebar/TypeSearchSidebar.unit.spec.tsx
 create mode 100644 frontend/src/metabase/search/components/TypeSearchSidebar/index.ts
 delete mode 100644 frontend/src/metabase/search/components/types.ts
 create mode 100644 frontend/src/metabase/search/constants.ts
 create mode 100644 frontend/src/metabase/search/containers/SearchApp.unit.spec.tsx
 create mode 100644 frontend/src/metabase/search/containers/constants.ts
 create mode 100644 frontend/src/metabase/search/types.ts
 create mode 100644 frontend/src/metabase/search/utils/index.ts
 create mode 100644 frontend/src/metabase/search/utils/search-location/index.ts
 create mode 100644 frontend/src/metabase/search/utils/search-location/search-location.ts
 create mode 100644 frontend/src/metabase/search/utils/search-location/search-location.unit.spec.ts

diff --git a/e2e/test/scenarios/embedding/embedding-full-app.cy.spec.js b/e2e/test/scenarios/embedding/embedding-full-app.cy.spec.js
index 2fb6903ecb6..f4696943b76 100644
--- a/e2e/test/scenarios/embedding/embedding-full-app.cy.spec.js
+++ b/e2e/test/scenarios/embedding/embedding-full-app.cy.spec.js
@@ -311,7 +311,7 @@ describeEE("scenarios > embedding > full app", () => {
       // will force the cursor to move away from the app bar, if
       // the cursor is still on the app bar, the logo will not be
       // be visible, since we'll only see the side bar toggle button.
-      cy.findByRole("button", { name: /Filter/i }).realHover();
+      cy.findByTestId("question-filter-header").realHover();
 
       cy.findByTestId("main-logo").should("be.visible");
     });
diff --git a/e2e/test/scenarios/onboarding/search/search.cy.spec.js b/e2e/test/scenarios/onboarding/search/search.cy.spec.js
index 4b322008f1a..b81143c62d3 100644
--- a/e2e/test/scenarios/onboarding/search/search.cy.spec.js
+++ b/e2e/test/scenarios/onboarding/search/search.cy.spec.js
@@ -7,25 +7,84 @@ import {
   restore,
 } from "e2e/support/helpers";
 import { ORDERS_QUESTION_ID } from "e2e/support/cypress_sample_instance_data";
+import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
+import {
+  createMetric,
+  createSegment,
+} from "e2e/support/helpers/e2e-table-metadata-helpers";
+
+const typeFilters = [
+  {
+    label: "Question",
+    sidebarLabel: "Questions",
+    filterName: "card",
+    resultInfoText: "Saved question in",
+  },
+  {
+    label: "Dashboard",
+    sidebarLabel: "Dashboards",
+    filterName: "dashboard",
+    resultInfoText: "Dashboard in",
+  },
+  {
+    label: "Collection",
+    sidebarLabel: "Collections",
+    filterName: "collection",
+    resultInfoText: "Collection",
+  },
+  {
+    label: "Metric",
+    sidebarLabel: "Metrics",
+    filterName: "metric",
+    resultInfoText: "Metric for",
+  },
+  {
+    label: "Segment",
+    sidebarLabel: "Segments",
+    filterName: "segment",
+    resultInfoText: "Segment of",
+  },
+  {
+    label: "Table",
+    sidebarLabel: "Raw Tables",
+    filterName: "table",
+    resultInfoText: "Table in",
+  },
+  {
+    label: "Database",
+    sidebarLabel: "Databases",
+    filterName: "database",
+    resultInfoText: "Database",
+  },
+  {
+    label: "Model",
+    sidebarLabel: "Models",
+    filterName: "dataset",
+    resultInfoText: "Model in",
+  },
+];
+
+const { ORDERS_ID, ORDERS } = SAMPLE_DATABASE;
 
 describe("scenarios > search", () => {
-  beforeEach(restore);
+  beforeEach(() => {
+    restore();
+    cy.intercept("GET", "/api/search?q=*").as("search");
+  });
 
   describe("universal search", () => {
     it("should work for admin (metabase#20018)", () => {
       cy.signInAsAdmin();
 
       cy.visit("/");
-      cy.findByPlaceholderText("Search…")
-        .as("searchBox")
-        .type("product")
-        .blur();
+      getSearchBar().as("searchBox").type("product").blur();
 
       cy.findByTestId("search-results-list").within(() => {
         getProductsSearchResults();
       });
 
       cy.get("@searchBox").type("{enter}");
+      cy.wait("@search");
 
       cy.findByTestId("search-result-item").within(() => {
         getProductsSearchResults();
@@ -35,25 +94,27 @@ describe("scenarios > search", () => {
     it("should work for user with permissions (metabase#12332)", () => {
       cy.signInAsNormalUser();
       cy.visit("/");
-      cy.findByPlaceholderText("Search…").type("product{enter}");
-      // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
-      cy.findByText("Products");
+      getSearchBar().type("product{enter}");
+      cy.wait("@search");
+      cy.findByTestId("search-app").within(() => {
+        cy.findByText("Products");
+      });
     });
 
     it("should work for user without data permissions (metabase#16855)", () => {
       cy.signIn("nodata");
       cy.visit("/");
-      cy.findByPlaceholderText("Search…").type("product{enter}");
-      // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
-      cy.findByText("Didn't find anything");
+      getSearchBar().type("product{enter}");
+      cy.wait("@search");
+      cy.findByTestId("search-app").within(() => {
+        cy.findByText("Didn't find anything");
+      });
     });
 
     it("allows to select a search result using keyboard", () => {
-      cy.intercept("GET", "/api/search*").as("search");
-
       cy.signInAsNormalUser();
       cy.visit("/");
-      cy.findByPlaceholderText("Search…").type("ord");
+      getSearchBar().type("ord");
       cy.wait("@search");
       cy.findAllByTestId("search-result-item-name")
         .first()
@@ -68,6 +129,168 @@ describe("scenarios > search", () => {
       );
     });
   });
+
+  describe("applying search filters", () => {
+    beforeEach(() => {
+      cy.signInAsAdmin();
+
+      createSegment({
+        name: "Segment",
+        description: "All orders with a total under $100.",
+        table_id: ORDERS_ID,
+        definition: {
+          "source-table": ORDERS_ID,
+          aggregation: [["count"]],
+          filter: ["<", ["field", ORDERS.TOTAL, null], 100],
+        },
+      });
+
+      createMetric({
+        name: "Metric",
+        description: "Sum of orders subtotal",
+        table_id: ORDERS_ID,
+        definition: {
+          "source-table": ORDERS_ID,
+          aggregation: [["sum", ["field", ORDERS.SUBTOTAL, null]]],
+        },
+      });
+
+      cy.createQuestion({
+        name: "Orders Model",
+        query: { "source-table": ORDERS_ID },
+        dataset: true,
+      });
+    });
+
+    describe("hydrating search query from URL", () => {
+      it("should hydrate search with search text", () => {
+        cy.visit("/search?q=orders");
+        cy.wait("@search");
+
+        getSearchBar().should("have.value", "orders");
+        cy.findByTestId("search-app").within(() => {
+          cy.findByText('Results for "orders"').should("exist");
+        });
+      });
+
+      it("should hydrate search with search text and filter", () => {
+        const { sidebarLabel, filterName, resultInfoText } = typeFilters[0];
+        cy.visit(`/search?q=orders&type=${filterName}`);
+        cy.wait("@search");
+
+        getSearchBar().should("have.value", "orders");
+        cy.findByTestId("search-bar-filter-button").should(
+          "have.attr",
+          "data-is-filtered",
+          "true",
+        );
+
+        cy.findByTestId("search-app").within(() => {
+          cy.findByText('Results for "orders"').should("exist");
+        });
+
+        cy.findAllByTestId("type-sidebar-item").should("have.length", 2);
+        cy.findByTestId("type-sidebar").within(() => {
+          cy.findByText(sidebarLabel).should("exist");
+        });
+        cy.findAllByTestId("result-link-info-text").each(result => {
+          cy.wrap(result).should("contain.text", resultInfoText);
+        });
+      });
+    });
+
+    describe("accessing full page search with `Enter`", () => {
+      it("should not render full page search if user has not entered a text query ", () => {
+        cy.intercept("GET", "/api/activity/recent_views").as("getRecentViews");
+
+        cy.visit("/");
+
+        cy.findByTestId("search-bar-filter-button").click();
+        getSearchModalContainer().within(() => {
+          cy.findByText("Question").click();
+          cy.findByText("Apply all filters").click();
+        });
+        getSearchBar().click().type("{enter}");
+
+        cy.wait("@getRecentViews");
+
+        cy.findByTestId("search-results-floating-container").within(() => {
+          cy.findByText("Recently viewed").should("exist");
+        });
+        cy.location("pathname").should("eq", "/");
+      });
+
+      it("should render full page search when search text is present and user clicks 'Enter'", () => {
+        cy.visit("/");
+        cy.findByTestId("search-bar-filter-button").click();
+        getSearchModalContainer().within(() => {
+          cy.findByText("Question").click();
+          cy.findByText("Apply all filters").click();
+        });
+        getSearchBar().click().type("orders{enter}");
+        cy.wait("@search");
+
+        cy.findByTestId("search-app").within(() => {
+          cy.findByText('Results for "orders"').should("exist");
+        });
+
+        cy.location().should(loc => {
+          expect(loc.pathname).to.eq("/search");
+          expect(loc.search).to.eq("?q=orders&type=card");
+        });
+      });
+    });
+
+    describe("search filters", () => {
+      typeFilters.forEach(
+        ({ label, sidebarLabel, filterName, resultInfoText }) => {
+          it(`should filter results by ${label}`, () => {
+            cy.visit("/");
+
+            cy.findByTestId("search-bar-filter-button").click();
+            getSearchModalContainer().within(() => {
+              cy.findByText(label).click();
+              cy.findByText("Apply all filters").click();
+            });
+
+            getSearchBar().clear().type("e{enter}");
+            cy.wait("@search");
+
+            cy.url().should("include", `type=${filterName}`);
+
+            cy.findAllByTestId("result-link-info-text").each(result => {
+              cy.wrap(result).should("contain.text", resultInfoText);
+            });
+
+            cy.findAllByTestId("type-sidebar-item").should("have.length", 2);
+            cy.findByTestId("type-sidebar").within(() => {
+              cy.findByText(sidebarLabel).should("exist");
+            });
+          });
+        },
+      );
+
+      it("should not filter results when `Clear all filters` is applied", () => {
+        cy.visit("/search?q=order&type=card");
+        cy.wait("@search");
+
+        cy.findAllByTestId("search-result-item-name");
+        cy.findByTestId("search-bar-filter-button").click();
+
+        getSearchModalContainer().within(() => {
+          cy.findByText("Clear all filters").click();
+        });
+
+        getSearchBar().clear().type("e{enter}");
+        cy.wait("@search");
+
+        cy.findAllByTestId("type-sidebar-item").should(
+          "have.length",
+          typeFilters.length + 1,
+        );
+      });
+    });
+  });
 });
 
 describeWithSnowplow("scenarios > search", () => {
@@ -87,7 +310,7 @@ describeWithSnowplow("scenarios > search", () => {
   it("should send snowplow events for global search queries", () => {
     cy.visit("/");
     expectGoodSnowplowEvents(PAGE_VIEW_EVENT);
-    cy.findByPlaceholderText("Search…").type("Orders").blur();
+    getSearchBar().type("Orders").blur();
     expectGoodSnowplowEvents(PAGE_VIEW_EVENT + 1); // new_search_query
   });
 });
@@ -99,3 +322,11 @@ function getProductsSearchResults() {
     "Includes a catalog of all the products ever sold by the famed Sample Company.",
   );
 }
+
+function getSearchBar() {
+  return cy.findByPlaceholderText("Search…");
+}
+
+function getSearchModalContainer() {
+  return cy.findByTestId("search-filter-modal-container");
+}
diff --git a/e2e/test/scenarios/question/notebook.cy.spec.js b/e2e/test/scenarios/question/notebook.cy.spec.js
index 5eca92130f9..e53abb1a0fd 100644
--- a/e2e/test/scenarios/question/notebook.cy.spec.js
+++ b/e2e/test/scenarios/question/notebook.cy.spec.js
@@ -66,7 +66,9 @@ describe("scenarios > question > notebook", () => {
     popover().within(() => {
       cy.contains("User ID").click();
     });
-    cy.icon("filter").click();
+    cy.findByTestId("step-summarize-0-0").within(() => {
+      cy.icon("filter").click();
+    });
     popover().within(() => {
       cy.icon("int").click();
       cy.get("input").type("46");
diff --git a/frontend/src/metabase/components/LoadingSpinner/LoadingSpinner.tsx b/frontend/src/metabase/components/LoadingSpinner/LoadingSpinner.tsx
index 2653cb3d8e7..6219e6fc7f1 100644
--- a/frontend/src/metabase/components/LoadingSpinner/LoadingSpinner.tsx
+++ b/frontend/src/metabase/components/LoadingSpinner/LoadingSpinner.tsx
@@ -7,10 +7,19 @@ interface Props {
   className?: string;
   size?: number;
   borderWidth?: number;
+  "data-testid"?: string;
 }
 
-const LoadingSpinner = ({ className, size = 32, borderWidth = 4 }: Props) => (
-  <SpinnerRoot className={className} data-testid="loading-spinner">
+const LoadingSpinner = ({
+  className,
+  size = 32,
+  borderWidth = 4,
+  "data-testid": dataTestId,
+}: Props) => (
+  <SpinnerRoot
+    className={className}
+    data-testid={dataTestId ?? "loading-spinner"}
+  >
     {isReducedMotionPreferred() ? (
       <Icon name="hourglass" size="24" />
     ) : (
diff --git a/frontend/src/metabase/nav/components/SearchBar.styled.tsx b/frontend/src/metabase/nav/components/SearchBar.styled.tsx
index 9aebeaef39b..bbd1ba1dfd4 100644
--- a/frontend/src/metabase/nav/components/SearchBar.styled.tsx
+++ b/frontend/src/metabase/nav/components/SearchBar.styled.tsx
@@ -11,6 +11,7 @@ import {
   breakpointMaxSmall,
   breakpointMinSmall,
 } from "metabase/styled-components/theme";
+import Button from "metabase/core/components/Button";
 
 const activeInputCSS = css`
   border-radius: 6px;
@@ -177,3 +178,13 @@ export const SearchResultsContainer = styled.div`
     box-shadow: 0 7px 20px ${color("shadow")};
   }
 `;
+
+export const SearchFunnelButton = styled(Button)<{ isFiltered?: boolean }>`
+  margin-right: 0.25rem;
+  border: none;
+
+  ${({ isFiltered }) => isFiltered && `color: ${color("brand")};`}
+  &:hover {
+    background: transparent;
+  }
+`;
diff --git a/frontend/src/metabase/nav/components/SearchBar.tsx b/frontend/src/metabase/nav/components/SearchBar.tsx
index 0be59c0b700..d69c213c8b5 100644
--- a/frontend/src/metabase/nav/components/SearchBar.tsx
+++ b/frontend/src/metabase/nav/components/SearchBar.tsx
@@ -2,7 +2,7 @@ import { MouseEvent, useEffect, useCallback, useRef, useState } from "react";
 import { t } from "ttag";
 import { push } from "react-router-redux";
 import { withRouter } from "react-router";
-import { Location, LocationDescriptorObject } from "history";
+import { LocationDescriptorObject } from "history";
 
 import { usePrevious } from "react-use";
 import { Icon } from "metabase/core/components/Icon";
@@ -11,12 +11,20 @@ import { useKeyboardShortcut } from "metabase/hooks/use-keyboard-shortcut";
 import { useOnClickOutside } from "metabase/hooks/use-on-click-outside";
 import { useToggle } from "metabase/hooks/use-toggle";
 import { isSmallScreen } from "metabase/lib/dom";
-import MetabaseSettings from "metabase/lib/settings";
-import { useDispatch } from "metabase/lib/redux";
+import { useDispatch, useSelector } from "metabase/lib/redux";
 import { zoomInRow } from "metabase/query_builder/actions";
 
-import SearchResults from "./SearchResults";
-import RecentsList from "./RecentsList";
+import { getSetting } from "metabase/selectors/settings";
+import RecentsList from "metabase/nav/components/RecentsList";
+import { SearchFilterModal } from "metabase/search/components/SearchFilterModal/SearchFilterModal";
+import SearchResults from "metabase/nav/components/SearchResults";
+
+import { SearchAwareLocation } from "metabase/search/types";
+import {
+  getFiltersFromLocation,
+  getSearchTextFromLocation,
+  isSearchPageLocation,
+} from "metabase/search/utils";
 import {
   SearchInputContainer,
   SearchIcon,
@@ -25,12 +33,11 @@ import {
   SearchResultsFloatingContainer,
   SearchResultsContainer,
   SearchBarRoot,
+  SearchFunnelButton,
 } from "./SearchBar.styled";
 
 const ALLOWED_SEARCH_FOCUS_ELEMENTS = new Set(["BODY", "A"]);
 
-type SearchAwareLocation = Location<{ q?: string }>;
-
 type RouterProps = {
   location: SearchAwareLocation;
 };
@@ -42,23 +49,19 @@ type OwnProps = {
 
 type Props = RouterProps & OwnProps;
 
-function isSearchPageLocation(location: Location) {
-  const components = location.pathname.split("/");
-  return components[components.length - 1];
-}
-
-function getSearchTextFromLocation(location: SearchAwareLocation) {
-  if (isSearchPageLocation(location)) {
-    return location.query.q || "";
-  }
-  return "";
-}
-
 function SearchBarView({ location, onSearchActive, onSearchInactive }: Props) {
-  const [searchText, setSearchText] = useState<string>(() =>
+  const isTypeaheadEnabled = useSelector(state =>
+    getSetting(state, "search-typeahead-enabled"),
+  );
+
+  const [searchText, setSearchText] = useState<string>(
     getSearchTextFromLocation(location),
   );
 
+  const [searchFilters, setSearchFilters] = useState(
+    getFiltersFromLocation(location),
+  );
+
   const [isActive, { turnOn: setActive, turnOff: setInactive }] =
     useToggle(false);
 
@@ -68,6 +71,8 @@ function SearchBarView({ location, onSearchActive, onSearchInactive }: Props) {
   const searchInput = useRef<HTMLInputElement>(null);
   const dispatch = useDispatch();
 
+  const hasSearchText = searchText.trim().length > 0;
+
   const onChangeLocation = useCallback(
     (nextLocation: LocationDescriptorObject) => dispatch(push(nextLocation)),
     [dispatch],
@@ -139,23 +144,32 @@ function SearchBarView({ location, onSearchActive, onSearchInactive }: Props) {
     }
   }, [previousLocation, location, setInactive]);
 
+  useEffect(() => {
+    if (!isSearchPageLocation(location)) {
+      setSearchFilters({});
+    }
+  }, [location]);
+
+  const onApplyFilter = useCallback(
+    filters => {
+      onInputContainerClick();
+      setSearchFilters(filters);
+    },
+    [onInputContainerClick],
+  );
+
   const handleInputKeyPress = useCallback(
     e => {
-      const hasSearchQuery =
-        typeof searchText === "string" && searchText.trim().length > 0;
-
-      if (e.key === "Enter" && hasSearchQuery) {
+      if (e.key === "Enter" && hasSearchText) {
         onChangeLocation({
           pathname: "search",
-          query: { q: searchText.trim() },
+          query: { q: searchText.trim(), ...searchFilters },
         });
       }
     },
-    [searchText, onChangeLocation],
+    [hasSearchText, onChangeLocation, searchFilters, searchText],
   );
 
-  const hasSearchText = searchText.trim().length > 0;
-
   const handleClickOnClose = useCallback(
     (e: MouseEvent) => {
       e.stopPropagation();
@@ -164,6 +178,9 @@ function SearchBarView({ location, onSearchActive, onSearchInactive }: Props) {
     [setInactive],
   );
 
+  const [isFilterModalOpen, setIsFilterModalOpen] = useState(false);
+  const isFiltered = Object.keys(searchFilters).length > 0;
+
   return (
     <SearchBarRoot ref={container}>
       <SearchInputContainer isActive={isActive} onClick={onInputContainerClick}>
@@ -177,18 +194,29 @@ function SearchBarView({ location, onSearchActive, onSearchInactive }: Props) {
           onKeyPress={handleInputKeyPress}
           ref={searchInput}
         />
+        <SearchFunnelButton
+          icon="filter"
+          data-is-filtered={isFiltered}
+          data-testid="search-bar-filter-button"
+          isFiltered={isFiltered}
+          onClick={e => {
+            e.stopPropagation();
+            setIsFilterModalOpen(true);
+          }}
+        />
         {isSmallScreen() && isActive && (
           <CloseSearchButton onClick={handleClickOnClose}>
             <Icon name="close" />
           </CloseSearchButton>
         )}
       </SearchInputContainer>
-      {isActive && MetabaseSettings.searchTypeaheadEnabled() && (
-        <SearchResultsFloatingContainer>
+      {isActive && isTypeaheadEnabled && (
+        <SearchResultsFloatingContainer data-testid="search-results-floating-container">
           {hasSearchText ? (
             <SearchResultsContainer data-testid="search-bar-results-container">
               <SearchResults
                 searchText={searchText.trim()}
+                searchFilters={searchFilters}
                 onEntitySelect={onSearchItemSelect}
               />
             </SearchResultsContainer>
@@ -197,6 +225,12 @@ function SearchBarView({ location, onSearchActive, onSearchInactive }: Props) {
           )}
         </SearchResultsFloatingContainer>
       )}
+      <SearchFilterModal
+        isOpen={isFilterModalOpen}
+        setIsOpen={setIsFilterModalOpen}
+        value={searchFilters}
+        onChangeFilters={onApplyFilter}
+      />
     </SearchBarRoot>
   );
 }
diff --git a/frontend/src/metabase/nav/components/SearchBar.unit.spec.tsx b/frontend/src/metabase/nav/components/SearchBar.unit.spec.tsx
new file mode 100644
index 00000000000..fbbb12ad9f9
--- /dev/null
+++ b/frontend/src/metabase/nav/components/SearchBar.unit.spec.tsx
@@ -0,0 +1,181 @@
+import { Route } from "react-router";
+import userEvent from "@testing-library/user-event";
+import { waitFor, renderWithProviders, screen, within } from "__support__/ui";
+import {
+  setupRecentViewsEndpoints,
+  setupSearchEndpoints,
+} from "__support__/server-mocks";
+import {
+  createMockCollectionItem,
+  createMockModelObject,
+  createMockRecentItem,
+} from "metabase-types/api/mocks";
+import {
+  createMockSettingsState,
+  createMockState,
+} from "metabase-types/store/mocks";
+import { CollectionItem, RecentItem } from "metabase-types/api";
+import SearchBar from "metabase/nav/components/SearchBar";
+import { checkNotNull } from "metabase/core/utils/types";
+
+const TEST_SEARCH_RESULTS: CollectionItem[] = [
+  "Card ABC",
+  "Card BCD",
+  "Card CDE",
+  "Card DEF",
+].map((name, index) =>
+  createMockCollectionItem({
+    name,
+    id: index + 1,
+    getUrl: () => "/",
+  }),
+);
+
+const TEST_RECENT_VIEWS_RESULTS: RecentItem[] = [
+  "Recents ABC",
+  "Recents BCD",
+  "Recents CDE",
+  "Recents DEF",
+].map((name, index) =>
+  createMockRecentItem({
+    model_object: createMockModelObject({ name }),
+    model_id: index + 1,
+  }),
+);
+
+const setup = ({
+  initialRoute = "/",
+  searchResultItems = TEST_SEARCH_RESULTS,
+  recentViewsItems = TEST_RECENT_VIEWS_RESULTS,
+} = {}) => {
+  const state = createMockState({
+    settings: createMockSettingsState({
+      "search-typeahead-enabled": true,
+    }),
+  });
+
+  setupSearchEndpoints(searchResultItems);
+  setupRecentViewsEndpoints(recentViewsItems);
+
+  const { history } = renderWithProviders(
+    <Route path="*" component={SearchBar} />,
+    {
+      withRouter: true,
+      initialRoute,
+      storeInitialState: state,
+    },
+  );
+
+  return { history: checkNotNull(history) };
+};
+
+const getSearchBar = () => {
+  return screen.getByPlaceholderText("Search…");
+};
+
+describe("SearchBar", () => {
+  describe("typing a search query", () => {
+    it("should change URL when user types a query and hits `Enter`", async () => {
+      const { history } = setup();
+
+      userEvent.type(getSearchBar(), "BC{enter}");
+
+      const location = history.getCurrentLocation();
+      expect(location.pathname).toEqual("search");
+      expect(location.search).toEqual("?q=BC");
+    });
+
+    it("should render 'No Results Found' when the query has no results", async () => {
+      setup({ searchResultItems: [] });
+      const searchBar = getSearchBar();
+      userEvent.type(searchBar, "XXXXX");
+      await waitFor(() =>
+        expect(screen.queryByTestId("loading-spinner")).not.toBeInTheDocument(),
+      );
+
+      expect(screen.getByText("Didn't find anything")).toBeInTheDocument();
+    });
+  });
+
+  describe("focusing on search bar", () => {
+    it("should render `Recent Searches` list when clicking the search bar", async () => {
+      setup();
+      userEvent.click(getSearchBar());
+      expect(await screen.findByText("Recents ABC")).toBeInTheDocument();
+    });
+
+    it("should render `Nothing here` and a folder icon if there are no recently viewed items", async () => {
+      setup({ recentViewsItems: [] });
+      userEvent.click(getSearchBar());
+      expect(await screen.findByText("Nothing here")).toBeInTheDocument();
+    });
+  });
+
+  describe("keyboard navigation", () => {
+    it("should focus on the filter bar when the user tabs from the search bar", () => {
+      setup();
+      userEvent.click(getSearchBar());
+      userEvent.tab();
+      expect(screen.getByTestId("search-bar-filter-button")).toHaveFocus();
+    });
+
+    it("should allow navigation through the search results with the keyboard", async () => {
+      setup();
+      userEvent.click(getSearchBar());
+      userEvent.type(getSearchBar(), "BC");
+
+      const resultItems = await screen.findAllByTestId("search-result-item");
+      expect(resultItems.length).toBe(2);
+
+      // tab over the filter button
+      userEvent.tab();
+
+      // There are two search results, each with a link to `Our analytics`,
+      // so we want to navigate to the search result, then the collection link.
+      for (const cardName of ["Card ABC", "Card BCD"]) {
+        userEvent.tab();
+
+        const filteredElement = resultItems.find(element =>
+          element.textContent?.includes(cardName),
+        );
+
+        expect(filteredElement).not.toBeUndefined();
+        expect(filteredElement).toHaveFocus();
+
+        userEvent.tab();
+
+        expect(
+          within(filteredElement as HTMLElement).getByText("Our analytics"),
+        ).toHaveFocus();
+      }
+    });
+  });
+
+  describe("populating existing query", () => {
+    it("should populate text and highlight filter button when a query is in the search bar", () => {
+      setup({
+        initialRoute: "/search?q=foo&type=card",
+      });
+
+      expect(getSearchBar()).toHaveValue("foo");
+
+      expect(screen.getByTestId("search-bar-filter-button")).toHaveAttribute(
+        "data-is-filtered",
+        "true",
+      );
+    });
+
+    it("should not populate text or highlight filter button on non-search pages", () => {
+      setup({
+        initialRoute: "/collection/root?q=foo&type=card&type=dashboard",
+      });
+
+      expect(getSearchBar()).toHaveValue("");
+
+      expect(screen.getByTestId("search-bar-filter-button")).toHaveAttribute(
+        "data-is-filtered",
+        "false",
+      );
+    });
+  });
+});
diff --git a/frontend/src/metabase/nav/components/SearchResults.jsx b/frontend/src/metabase/nav/components/SearchResults.jsx
index e31b52f343f..f50f474de07 100644
--- a/frontend/src/metabase/nav/components/SearchResults.jsx
+++ b/frontend/src/metabase/nav/components/SearchResults.jsx
@@ -18,6 +18,7 @@ const propTypes = {
   onEntitySelect: PropTypes.func,
   forceEntitySelect: PropTypes.bool,
   searchText: PropTypes.string,
+  searchFilters: PropTypes.object,
 };
 
 const SearchResults = ({
@@ -81,10 +82,14 @@ export default _.compose(
     wrapped: true,
     reload: true,
     debounced: true,
-    query: (_state, props) => ({
-      q: props.searchText,
-      limit: DEFAULT_SEARCH_LIMIT,
-      models: props.models,
-    }),
+    query: (_state, props) => {
+      const searchFilters = props.searchFilters || {};
+      return {
+        q: props.searchText,
+        limit: DEFAULT_SEARCH_LIMIT,
+        ...searchFilters,
+        models: searchFilters.type,
+      };
+    },
   }),
 )(SearchResults);
diff --git a/frontend/src/metabase/nav/utils/model-names.ts b/frontend/src/metabase/nav/utils/model-names.ts
index 147bd545d1b..da0374b501f 100644
--- a/frontend/src/metabase/nav/utils/model-names.ts
+++ b/frontend/src/metabase/nav/utils/model-names.ts
@@ -1,15 +1,17 @@
 import { t } from "ttag";
 
 const TRANSLATED_NAME_BY_MODEL_TYPE: Record<string, string> = {
+  action: t`Action`,
   card: t`Question`,
-  dataset: t`Model`,
+  collection: t`Collection`,
   dashboard: t`Dashboard`,
-  table: t`Table`,
   database: t`Database`,
-  collection: t`Collection`,
-  segment: t`Segment`,
+  dataset: t`Model`,
+  "indexed-entity": "Indexed Entity",
   metric: t`Metric`,
   pulse: t`Pulse`,
+  segment: t`Segment`,
+  table: t`Table`,
 };
 
 export const getTranslatedEntityName = (type: string) =>
diff --git a/frontend/src/metabase/query_builder/components/view/QuestionFilters.jsx b/frontend/src/metabase/query_builder/components/view/QuestionFilters.jsx
index de238007fb0..fc3d7693684 100644
--- a/frontend/src/metabase/query_builder/components/view/QuestionFilters.jsx
+++ b/frontend/src/metabase/query_builder/components/view/QuestionFilters.jsx
@@ -159,6 +159,7 @@ export function QuestionFilterWidget({ onOpenModal, className }) {
       onClick={() => onOpenModal(MODAL_TYPES.FILTERS)}
       aria-label={t`Show more filters`}
       data-metabase-event="View Mode; Open Filter Modal"
+      data-testid="question-filter-header"
     >
       {t`Filter`}
     </HeaderButton>
diff --git a/frontend/src/metabase/search/components/InfoText.tsx b/frontend/src/metabase/search/components/InfoText.tsx
index b72828040a8..8488d10bcea 100644
--- a/frontend/src/metabase/search/components/InfoText.tsx
+++ b/frontend/src/metabase/search/components/InfoText.tsx
@@ -12,10 +12,10 @@ import { PLUGIN_COLLECTIONS } from "metabase/plugins";
 import { getTranslatedEntityName } from "metabase/nav/utils";
 
 import type { Collection } from "metabase-types/api";
+import type { WrappedResult } from "metabase/search/types";
 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;
diff --git a/frontend/src/metabase/search/components/SearchFilterModal/SearchFilterModal.styled.tsx b/frontend/src/metabase/search/components/SearchFilterModal/SearchFilterModal.styled.tsx
new file mode 100644
index 00000000000..33cfecb64ea
--- /dev/null
+++ b/frontend/src/metabase/search/components/SearchFilterModal/SearchFilterModal.styled.tsx
@@ -0,0 +1,10 @@
+import styled from "@emotion/styled";
+import { color } from "metabase/lib/colors";
+
+export const SearchFilterWrapper = styled.div`
+  & > * {
+    border-bottom: 1px solid ${color("border")};
+    padding: 1.5rem 2rem;
+    margin: 0;
+  }
+`;
diff --git a/frontend/src/metabase/search/components/SearchFilterModal/SearchFilterModal.tsx b/frontend/src/metabase/search/components/SearchFilterModal/SearchFilterModal.tsx
new file mode 100644
index 00000000000..2e89d17cea5
--- /dev/null
+++ b/frontend/src/metabase/search/components/SearchFilterModal/SearchFilterModal.tsx
@@ -0,0 +1,100 @@
+import { t } from "ttag";
+import { useEffect, useMemo, useState } from "react";
+import _ from "underscore";
+import Modal from "metabase/components/Modal";
+import { SearchFilterModalFooter } from "metabase/search/components/SearchFilterModal/SearchFilterModalFooter";
+import {
+  FilterTypeKeys,
+  SearchFilterComponent,
+  SearchFilterPropTypes,
+  SearchFilters,
+} from "metabase/search/types";
+import Button from "metabase/core/components/Button";
+import { Title, Flex } from "metabase/ui";
+import { SearchFilterKeys } from "metabase/search/constants";
+import { TypeFilter } from "./filters/TypeFilter";
+import { SearchFilterWrapper } from "./SearchFilterModal.styled";
+
+const filterMap: Record<FilterTypeKeys, SearchFilterComponent> = {
+  [SearchFilterKeys.Type]: TypeFilter,
+};
+
+export const SearchFilterModal = ({
+  isOpen,
+  setIsOpen,
+  value,
+  onChangeFilters,
+}: {
+  isOpen: boolean;
+  setIsOpen: (isOpen: boolean) => void;
+  value: SearchFilters;
+  onChangeFilters: (filters: SearchFilters) => void;
+}) => {
+  const [output, setOutput] = useState<SearchFilters>(value);
+
+  const onOutputChange = (
+    key: FilterTypeKeys,
+    val: SearchFilterPropTypes[FilterTypeKeys],
+  ) => {
+    if (!val || val.length === 0) {
+      setOutput(_.omit(output, key));
+    } else {
+      setOutput({
+        ...output,
+        [key]: val,
+      });
+    }
+  };
+
+  useEffect(() => {
+    setOutput(value);
+  }, [value]);
+
+  const closeModal = () => {
+    setIsOpen(false);
+  };
+
+  const clearFilters = () => {
+    onChangeFilters({});
+    setIsOpen(false);
+  };
+
+  const applyFilters = () => {
+    onChangeFilters(output);
+    setIsOpen(false);
+  };
+
+  // we can use this field to control which filters are available
+  // - we can enable the 'verified' filter here
+  const availableFilters: FilterTypeKeys[] = useMemo(() => {
+    return [SearchFilterKeys.Type];
+  }, []);
+
+  return isOpen ? (
+    <Modal isOpen={isOpen} onClose={closeModal}>
+      <SearchFilterWrapper data-testid="search-filter-modal-container">
+        <Flex direction="row" justify="space-between" align="center">
+          <Title order={4}>{t`Filter by`}</Title>
+          <Button onlyIcon onClick={() => setIsOpen(false)} icon="close" />
+        </Flex>
+        {availableFilters.map(key => {
+          const Filter = filterMap[key];
+          return (
+            <Filter
+              key={key}
+              data-testid={`${key}-search-filter`}
+              value={output[key]}
+              onChange={value => onOutputChange(key, value)}
+            />
+          );
+        })}
+
+        <SearchFilterModalFooter
+          onApply={applyFilters}
+          onCancel={closeModal}
+          onClear={clearFilters}
+        />
+      </SearchFilterWrapper>
+    </Modal>
+  ) : null;
+};
diff --git a/frontend/src/metabase/search/components/SearchFilterModal/SearchFilterModal.unit.spec.tsx b/frontend/src/metabase/search/components/SearchFilterModal/SearchFilterModal.unit.spec.tsx
new file mode 100644
index 00000000000..a6d41bad324
--- /dev/null
+++ b/frontend/src/metabase/search/components/SearchFilterModal/SearchFilterModal.unit.spec.tsx
@@ -0,0 +1,125 @@
+import { useState } from "react";
+import userEvent from "@testing-library/user-event";
+import { waitFor, within, renderWithProviders, screen } from "__support__/ui";
+import { setupSearchEndpoints } from "__support__/server-mocks";
+import { createMockSearchResult } from "metabase-types/api/mocks";
+import { SearchFilterModal } from "metabase/search/components/SearchFilterModal/SearchFilterModal";
+import { SearchModelType } from "metabase-types/api";
+import { SearchFilters } from "metabase/search/types";
+
+const TestSearchFilterModal = ({
+  initialFilters = {},
+  onChangeFilters,
+}: {
+  initialFilters?: SearchFilters;
+  onChangeFilters: jest.Mock;
+}) => {
+  const [filters, setFilters] = useState(initialFilters);
+
+  onChangeFilters.mockImplementation(newFilters => setFilters(newFilters));
+
+  return (
+    <SearchFilterModal
+      isOpen={true}
+      setIsOpen={jest.fn()}
+      value={filters}
+      onChangeFilters={onChangeFilters}
+    />
+  );
+};
+
+const TEST_TYPES: Array<SearchModelType> = [
+  "card",
+  "collection",
+  "dashboard",
+  "database",
+  "dataset",
+  "metric",
+  "pulse",
+  "segment",
+  "table",
+];
+
+const TEST_INITIAL_FILTERS: SearchFilters = {
+  type: TEST_TYPES,
+};
+
+const setup = async ({
+  initialFilters = {},
+  availableModelTypes = TEST_TYPES,
+} = {}) => {
+  const onChangeFilters = jest.fn();
+
+  setupSearchEndpoints(
+    availableModelTypes.map((type, index) =>
+      createMockSearchResult({ model: type, id: index + 1 }),
+    ),
+  );
+
+  renderWithProviders(
+    <TestSearchFilterModal
+      initialFilters={initialFilters}
+      onChangeFilters={onChangeFilters}
+    />,
+  );
+  await waitFor(() => {
+    expect(screen.queryByTestId("loading-spinner")).not.toBeInTheDocument();
+  });
+
+  return {
+    onChangeFilters,
+  };
+};
+
+describe("SearchFilterModal", () => {
+  it("should populate selected filters when `value` is passed in", async () => {
+    await setup({
+      initialFilters: TEST_INITIAL_FILTERS,
+    });
+
+    const typeFilter = screen.getByTestId("type-search-filter");
+    within(typeFilter)
+      .getAllByRole("checkbox")
+      .forEach(checkbox => {
+        expect(checkbox).toBeChecked();
+      });
+  });
+
+  it("should not populate filter object with key if key has no value", async () => {
+    const { onChangeFilters } = await setup({
+      initialFilters: TEST_INITIAL_FILTERS,
+    });
+    const typeFilter = screen.getByTestId("type-search-filter");
+    within(typeFilter)
+      .getAllByRole("checkbox")
+      .forEach(checkbox => {
+        userEvent.click(checkbox);
+      });
+    userEvent.click(screen.getByText("Apply all filters"));
+
+    expect(onChangeFilters).toHaveBeenCalledWith({});
+  });
+
+  it("should return all selected filters when `Apply all filters` is clicked", async () => {
+    const { onChangeFilters } = await setup({
+      initialFilters: TEST_INITIAL_FILTERS,
+    });
+
+    userEvent.click(screen.getByText("Apply all filters"));
+    expect(onChangeFilters).toHaveBeenCalledWith(TEST_INITIAL_FILTERS);
+  });
+
+  it("should clear all selections when `Clear all filters` is clicked", async () => {
+    const { onChangeFilters } = await setup();
+
+    userEvent.click(screen.getByText("Clear all filters"));
+    expect(onChangeFilters).toHaveBeenCalledWith({});
+  });
+
+  it("should not change filters when `Cancel` is clicked", async () => {
+    const { onChangeFilters } = await setup();
+
+    userEvent.click(screen.getByText("Cancel"));
+    expect(onChangeFilters).not.toHaveBeenCalled();
+  });
+});
diff --git a/frontend/src/metabase/search/components/SearchFilterModal/SearchFilterModalFooter.styled.tsx b/frontend/src/metabase/search/components/SearchFilterModal/SearchFilterModalFooter.styled.tsx
new file mode 100644
index 00000000000..bc5714d5667
--- /dev/null
+++ b/frontend/src/metabase/search/components/SearchFilterModal/SearchFilterModalFooter.styled.tsx
@@ -0,0 +1,13 @@
+import styled from "@emotion/styled";
+import Button from "metabase/core/components/Button";
+
+export const CloseAllFiltersButton = styled(Button)`
+  border: none;
+  padding-left: 0;
+  padding-right: 0;
+  background: none;
+
+  &:hover {
+    background: none;
+  }
+`;
diff --git a/frontend/src/metabase/search/components/SearchFilterModal/SearchFilterModalFooter.tsx b/frontend/src/metabase/search/components/SearchFilterModal/SearchFilterModalFooter.tsx
new file mode 100644
index 00000000000..996cfc7c324
--- /dev/null
+++ b/frontend/src/metabase/search/components/SearchFilterModal/SearchFilterModalFooter.tsx
@@ -0,0 +1,28 @@
+import { t } from "ttag";
+import Button from "metabase/core/components/Button";
+import { Flex, Group } from "metabase/ui";
+import { CloseAllFiltersButton } from "metabase/search/components/SearchFilterModal/SearchFilterModalFooter.styled";
+
+type SearchFilterModalFooterProps = {
+  onApply: () => void;
+  onCancel: () => void;
+  onClear: () => void;
+};
+
+export const SearchFilterModalFooter = ({
+  onApply,
+  onCancel,
+  onClear,
+}: SearchFilterModalFooterProps) => {
+  return (
+    <Flex direction={"row"} justify={"space-between"} align={"center"}>
+      <CloseAllFiltersButton
+        onClick={onClear}
+      >{t`Clear all filters`}</CloseAllFiltersButton>
+      <Group>
+        <Button onClick={onCancel}>{t`Cancel`}</Button>
+        <Button primary onClick={onApply}>{t`Apply all filters`}</Button>
+      </Group>
+    </Flex>
+  );
+};
diff --git a/frontend/src/metabase/search/components/SearchFilterModal/filters/SearchFilterView.tsx b/frontend/src/metabase/search/components/SearchFilterModal/filters/SearchFilterView.tsx
new file mode 100644
index 00000000000..75ffaf02171
--- /dev/null
+++ b/frontend/src/metabase/search/components/SearchFilterModal/filters/SearchFilterView.tsx
@@ -0,0 +1,35 @@
+import { ReactNode } from "react";
+import { Grid, Title } from "metabase/ui";
+import Tooltip from "metabase/core/components/Tooltip";
+import { Icon } from "metabase/core/components/Icon";
+import LoadingSpinner from "metabase/components/LoadingSpinner";
+
+export const SearchFilterView = ({
+  title,
+  tooltip,
+  isLoading,
+  "data-testid": dataTestId,
+  children,
+}: {
+  title: string;
+  tooltip?: string;
+  isLoading?: boolean;
+  "data-testid"?: string;
+  children: ReactNode;
+}) => {
+  return (
+    <Grid data-testid={dataTestId}>
+      <Grid.Col span={2} p={0}>
+        <Title order={5}>{title}</Title>
+        {tooltip && (
+          <Tooltip tooltip={tooltip}>
+            <Icon name="info_outline" />
+          </Tooltip>
+        )}
+      </Grid.Col>
+      <Grid.Col p={0} span="auto">
+        {isLoading ? <LoadingSpinner /> : children}
+      </Grid.Col>
+    </Grid>
+  );
+};
diff --git a/frontend/src/metabase/search/components/SearchFilterModal/filters/SearchFilterView.unit.spec.tsx b/frontend/src/metabase/search/components/SearchFilterModal/filters/SearchFilterView.unit.spec.tsx
new file mode 100644
index 00000000000..e9e217986b3
--- /dev/null
+++ b/frontend/src/metabase/search/components/SearchFilterModal/filters/SearchFilterView.unit.spec.tsx
@@ -0,0 +1,61 @@
+import { ReactNode } from "react";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { SearchFilterView } from "./SearchFilterView";
+
+type SetupProps = {
+  title?: string;
+  tooltip?: string;
+  isLoading?: boolean;
+  children?: ReactNode;
+};
+
+const setup = ({
+  title = "Title",
+  tooltip = undefined,
+  isLoading = false,
+  children = <div>Children</div>,
+}: SetupProps = {}) => {
+  render(
+    <SearchFilterView title={title} tooltip={tooltip} isLoading={isLoading}>
+      {children}
+    </SearchFilterView>,
+  );
+};
+
+describe("SearchFilterView", () => {
+  it("renders title and children without tooltip when not loading", () => {
+    setup();
+
+    expect(screen.getByText("Title")).toBeInTheDocument();
+    expect(screen.getByText("Children")).toBeInTheDocument();
+    expect(screen.queryByTestId("tooltip")).not.toBeInTheDocument();
+    expect(screen.queryByTestId("loading-spinner")).not.toBeInTheDocument();
+  });
+
+  it("renders title, children, and tooltip when not loading", () => {
+    const title = "Test Title";
+    const tooltip = "Test Tooltip";
+    const children = <div>Test Content</div>;
+
+    setup({ title, tooltip, children });
+
+    expect(screen.getByText(title)).toBeInTheDocument();
+    expect(screen.getByText("Test Content")).toBeInTheDocument();
+    const tooltipIcon = screen.getByLabelText("info_outline icon");
+    expect(tooltipIcon).toBeInTheDocument();
+    userEvent.hover(tooltipIcon);
+    expect(screen.getByText("Test Tooltip")).toBeInTheDocument();
+  });
+
+  it("renders loading spinner when isLoading is true", () => {
+    setup({ isLoading: true });
+
+    expect(screen.getByText("Title")).toBeInTheDocument();
+    expect(screen.queryByText("Children")).not.toBeInTheDocument();
+    expect(
+      screen.queryByLabelText("info_outline icon"),
+    ).not.toBeInTheDocument();
+    expect(screen.getByTestId("loading-spinner")).toBeInTheDocument();
+  });
+});
diff --git a/frontend/src/metabase/search/components/SearchFilterModal/filters/TypeFilter.styled.tsx b/frontend/src/metabase/search/components/SearchFilterModal/filters/TypeFilter.styled.tsx
new file mode 100644
index 00000000000..aaabf794550
--- /dev/null
+++ b/frontend/src/metabase/search/components/SearchFilterModal/filters/TypeFilter.styled.tsx
@@ -0,0 +1,7 @@
+import styled from "@emotion/styled";
+
+export const TypeCheckboxGroupWrapper = styled.div`
+  display: grid;
+  grid-template-columns: repeat(3, 1fr);
+  gap: 0.5rem 4.5rem;
+`;
diff --git a/frontend/src/metabase/search/components/SearchFilterModal/filters/TypeFilter.tsx b/frontend/src/metabase/search/components/SearchFilterModal/filters/TypeFilter.tsx
new file mode 100644
index 00000000000..41b581f8160
--- /dev/null
+++ b/frontend/src/metabase/search/components/SearchFilterModal/filters/TypeFilter.tsx
@@ -0,0 +1,51 @@
+/* eslint-disable react/prop-types */
+import { t } from "ttag";
+import { getTranslatedEntityName } from "metabase/nav/utils";
+import { Checkbox } from "metabase/ui";
+import { useSearchListQuery } from "metabase/common/hooks";
+import LoadingSpinner from "metabase/components/LoadingSpinner";
+import { SearchFilterView } from "metabase/search/components/SearchFilterModal/filters/SearchFilterView";
+
+import type { SearchFilterComponent } from "metabase/search/types";
+import { TypeCheckboxGroupWrapper } from "metabase/search/components/SearchFilterModal/filters/TypeFilter.styled";
+import { enabledSearchTypes } from "metabase/search/constants";
+
+const EMPTY_SEARCH_QUERY = { models: "dataset", limit: 1 } as const;
+
+export const TypeFilter: SearchFilterComponent<"type"> = ({
+  value = [],
+  onChange,
+  "data-testid": dataTestId,
+}) => {
+  const { metadata, isLoading } = useSearchListQuery({
+    query: EMPTY_SEARCH_QUERY,
+  });
+
+  const availableModels = (metadata && metadata.available_models) ?? [];
+  const typeFilters = availableModels.filter(model =>
+    enabledSearchTypes.includes(model),
+  );
+
+  return isLoading ? (
+    <LoadingSpinner />
+  ) : (
+    <SearchFilterView data-testid={dataTestId} title={t`Type`}>
+      <Checkbox.Group
+        value={value}
+        onChange={onChange}
+        data-testid="type-filter-checkbox-group"
+        inputContainer={children => (
+          <TypeCheckboxGroupWrapper>{children}</TypeCheckboxGroupWrapper>
+        )}
+      >
+        {typeFilters.map(model => (
+          <Checkbox
+            key={model}
+            value={model}
+            label={getTranslatedEntityName(model)}
+          />
+        ))}
+      </Checkbox.Group>
+    </SearchFilterView>
+  );
+};
diff --git a/frontend/src/metabase/search/components/SearchFilterModal/filters/TypeFilter.unit.spec.tsx b/frontend/src/metabase/search/components/SearchFilterModal/filters/TypeFilter.unit.spec.tsx
new file mode 100644
index 00000000000..dc08e3b473b
--- /dev/null
+++ b/frontend/src/metabase/search/components/SearchFilterModal/filters/TypeFilter.unit.spec.tsx
@@ -0,0 +1,155 @@
+import { useState } from "react";
+import userEvent from "@testing-library/user-event";
+import { renderWithProviders, screen, waitFor, within } from "__support__/ui";
+import { setupSearchEndpoints } from "__support__/server-mocks";
+import { createMockSearchResult } from "metabase-types/api/mocks";
+import { TypeFilter } from "metabase/search/components/SearchFilterModal/filters/TypeFilter";
+import { SearchModelType } from "metabase-types/api";
+
+const TRANSLATED_NAME_BY_MODEL_TYPE: Record<string, string> = {
+  card: "Question",
+  collection: "Collection",
+  dashboard: "Dashboard",
+  database: "Database",
+  dataset: "Model",
+  metric: "Metric",
+  pulse: "Pulse",
+  segment: "Segment",
+  table: "Table",
+};
+
+const TEST_TYPES: Array<SearchModelType> = [
+  "card",
+  "collection",
+  "dashboard",
+  "database",
+  "dataset",
+  "table",
+  "pulse",
+  "segment",
+  "metric",
+];
+
+const TEST_TYPE_SUBSET: Array<SearchModelType> = [
+  "dashboard",
+  "collection",
+  "database",
+];
+
+const TestTypeFilterComponent = ({
+  initialValue = [],
+  onChangeFilters,
+}: {
+  initialValue?: SearchModelType[];
+  onChangeFilters: jest.Mock;
+}) => {
+  const [value, setValue] = useState<SearchModelType[]>(initialValue);
+
+  onChangeFilters.mockImplementation((value: SearchModelType[]) => {
+    setValue(value);
+  });
+
+  return <TypeFilter value={value} onChange={onChangeFilters} />;
+};
+
+const setup = async ({
+  availableModels = TEST_TYPES,
+  initialValue = [],
+}: {
+  availableModels?: SearchModelType[];
+  initialValue?: SearchModelType[];
+} = {}) => {
+  setupSearchEndpoints(
+    availableModels.map((type, index) =>
+      createMockSearchResult({ model: type, id: index + 1 }),
+    ),
+  );
+
+  const onChangeFilters = jest.fn();
+
+  renderWithProviders(
+    <TestTypeFilterComponent
+      onChangeFilters={onChangeFilters}
+      initialValue={initialValue}
+    />,
+  );
+  await waitFor(() =>
+    expect(screen.queryByTestId("loading-spinner")).not.toBeInTheDocument(),
+  );
+
+  return {
+    onChangeFilters,
+  };
+};
+
+const getCheckboxes = () => {
+  return within(screen.getByTestId("type-filter-checkbox-group")).getAllByRole(
+    "checkbox",
+    {},
+  ) as HTMLInputElement[];
+};
+
+describe("TypeFilter", () => {
+  it("should display `Type` and all type labels", async () => {
+    await setup();
+    expect(screen.getByText("Type")).toBeInTheDocument();
+    for (const entityType of TEST_TYPES) {
+      expect(
+        screen.getByText(TRANSLATED_NAME_BY_MODEL_TYPE[entityType]),
+      ).toBeInTheDocument();
+    }
+  });
+
+  it("should only display available types", async () => {
+    await setup({ availableModels: TEST_TYPE_SUBSET });
+
+    const options = getCheckboxes();
+
+    expect(options).toHaveLength(TEST_TYPE_SUBSET.length);
+
+    options.forEach(option => {
+      expect(TEST_TYPE_SUBSET).toContain(option.value);
+    });
+  });
+
+  it("should populate the filter with initial values", async () => {
+    await setup({ initialValue: TEST_TYPE_SUBSET });
+
+    const options = getCheckboxes();
+
+    expect(options.length).toEqual(TEST_TYPES.length);
+
+    const checkedOptions = options.filter(option => option.checked);
+
+    expect(checkedOptions.length).toEqual(TEST_TYPE_SUBSET.length);
+    for (const checkedOption of checkedOptions) {
+      expect(TEST_TYPE_SUBSET).toContain(checkedOption.value);
+    }
+  });
+
+  it("should allow selecting multiple types", async () => {
+    const { onChangeFilters } = await setup();
+    const options = getCheckboxes();
+
+    for (let i = 0; i < options.length; i++) {
+      userEvent.click(options[i]);
+      expect(onChangeFilters).toHaveReturnedTimes(i + 1);
+    }
+
+    expect(onChangeFilters).toHaveReturnedTimes(TEST_TYPES.length);
+    expect(onChangeFilters).toHaveBeenLastCalledWith(TEST_TYPES);
+  });
+
+  it("should allow de-selecting multiple types", async () => {
+    const { onChangeFilters } = await setup({ initialValue: TEST_TYPE_SUBSET });
+
+    const options = getCheckboxes();
+    const checkedOptions = options.filter(option => option.checked);
+    for (const checkedOption of checkedOptions) {
+      userEvent.click(checkedOption);
+    }
+
+    expect(onChangeFilters).toHaveReturnedTimes(TEST_TYPE_SUBSET.length);
+    expect(onChangeFilters).toHaveBeenLastCalledWith([]);
+  });
+});
diff --git a/frontend/src/metabase/search/components/SearchResult.tsx b/frontend/src/metabase/search/components/SearchResult.tsx
index 4f1c6299a82..66055fc79fa 100644
--- a/frontend/src/metabase/search/components/SearchResult.tsx
+++ b/frontend/src/metabase/search/components/SearchResult.tsx
@@ -7,8 +7,7 @@ import { PLUGIN_COLLECTIONS, PLUGIN_MODERATION } from "metabase/plugins";
 
 import type { SearchScore, SearchModelType } from "metabase-types/api";
 
-import type { WrappedResult } from "./types";
-
+import type { WrappedResult } from "metabase/search/types";
 import {
   IconWrapper,
   ResultButton,
@@ -143,7 +142,7 @@ export function SearchResult({
               size={12}
             />
           </TitleWrapper>
-          <Text>
+          <Text data-testid="result-link-info-text">
             <InfoText result={result} />
           </Text>
           {hasDescription && result.description && (
@@ -151,7 +150,15 @@ export function SearchResult({
           )}
           <Score scores={result.scores} />
         </div>
-        {loading && <ResultSpinner size={24} borderWidth={3} />}
+        {loading && (
+          // SearchApp also uses `loading-spinner`, using a different test ID
+          // to not confuse unit tests waiting for loading-spinner to disappear
+          <ResultSpinner
+            data-testid="search-result-loading-spinner"
+            size={24}
+            borderWidth={3}
+          />
+        )}
       </ResultLinkContent>
       {compact || <Context context={result.context} />}
     </ResultContainer>
diff --git a/frontend/src/metabase/search/components/SearchResult.unit.spec.tsx b/frontend/src/metabase/search/components/SearchResult.unit.spec.tsx
index 622c0f9f318..ad4f46794bd 100644
--- a/frontend/src/metabase/search/components/SearchResult.unit.spec.tsx
+++ b/frontend/src/metabase/search/components/SearchResult.unit.spec.tsx
@@ -12,8 +12,8 @@ import {
 } from "metabase-types/api/mocks";
 import { getIcon, renderWithProviders, queryIcon } from "__support__/ui";
 
-import { InitialSyncStatus } from "metabase-types/api";
-import type { WrappedResult } from "./types";
+import type { InitialSyncStatus } from "metabase-types/api";
+import type { WrappedResult } from "metabase/search/types";
 import { SearchResult } from "./SearchResult";
 
 const createWrappedSearchResult = (
diff --git a/frontend/src/metabase/search/components/TypeSearchSidebar/TypeSearchSidebar.styled.tsx b/frontend/src/metabase/search/components/TypeSearchSidebar/TypeSearchSidebar.styled.tsx
new file mode 100644
index 00000000000..0031da9db22
--- /dev/null
+++ b/frontend/src/metabase/search/components/TypeSearchSidebar/TypeSearchSidebar.styled.tsx
@@ -0,0 +1,9 @@
+import styled from "@emotion/styled";
+import Button from "metabase/core/components/Button";
+import { color } from "metabase/lib/colors";
+
+export const TypeSidebarButton = styled(Button)<{ isActive: boolean }>`
+  color: ${props => (props.isActive ? color("brand") : color("text-medium"))};
+  border: none;
+  padding: 0 0 1.5rem 0;
+`;
diff --git a/frontend/src/metabase/search/components/TypeSearchSidebar/TypeSearchSidebar.tsx b/frontend/src/metabase/search/components/TypeSearchSidebar/TypeSearchSidebar.tsx
new file mode 100644
index 00000000000..23aabcb194f
--- /dev/null
+++ b/frontend/src/metabase/search/components/TypeSearchSidebar/TypeSearchSidebar.tsx
@@ -0,0 +1,49 @@
+import { t } from "ttag";
+import { Flex } from "@mantine/core";
+import { TypeSidebarButton } from "metabase/search/components/TypeSearchSidebar/TypeSearchSidebar.styled";
+import { SearchModelType } from "metabase-types/api";
+import { SEARCH_FILTERS } from "metabase/search/constants";
+
+export const TypeSearchSidebar = ({
+  availableModels,
+  selectedType = null,
+  onSelectType,
+}: {
+  availableModels: SearchModelType[];
+  selectedType: SearchModelType | null;
+  onSelectType: (type: SearchModelType | null) => void;
+}) => {
+  const searchModels = [
+    {
+      name: t`All results`,
+      icon: "search",
+      filter: null,
+    },
+    ...SEARCH_FILTERS.filter(({ filter }) => availableModels.includes(filter)),
+  ];
+
+  return (
+    <Flex
+      data-testid="type-sidebar"
+      align={"flex-start"}
+      justify={"center"}
+      direction={"column"}
+      pt="1rem"
+    >
+      {searchModels.map(({ name, icon, filter }) => {
+        return (
+          <TypeSidebarButton
+            data-testid="type-sidebar-item"
+            key={name}
+            onClick={() => onSelectType(filter)}
+            icon={icon}
+            iconSize={16}
+            isActive={filter === selectedType}
+          >
+            <h4>{name}</h4>
+          </TypeSidebarButton>
+        );
+      })}
+    </Flex>
+  );
+};
diff --git a/frontend/src/metabase/search/components/TypeSearchSidebar/TypeSearchSidebar.unit.spec.tsx b/frontend/src/metabase/search/components/TypeSearchSidebar/TypeSearchSidebar.unit.spec.tsx
new file mode 100644
index 00000000000..14d2931196f
--- /dev/null
+++ b/frontend/src/metabase/search/components/TypeSearchSidebar/TypeSearchSidebar.unit.spec.tsx
@@ -0,0 +1,119 @@
+import { useState } from "react";
+import { IconName } from "metabase/core/components/Icon";
+import { TypeSearchSidebar } from "metabase/search/components/TypeSearchSidebar/TypeSearchSidebar";
+import { screen, render, within } from "__support__/ui";
+import { SearchModelType } from "metabase-types/api";
+
+const TEST_TYPES: {
+  name: string;
+  icon: IconName;
+  filter: SearchModelType;
+}[] = [
+  {
+    name: "Dashboards",
+    filter: "dashboard",
+    icon: "dashboard",
+  },
+  {
+    name: "Collections",
+    filter: "collection",
+    icon: "folder",
+  },
+  {
+    name: "Databases",
+    filter: "database",
+    icon: "database",
+  },
+  {
+    name: "Models",
+    filter: "dataset",
+    icon: "model",
+  },
+  {
+    name: "Raw Tables",
+    filter: "table",
+    icon: "table",
+  },
+  {
+    name: "Questions",
+    filter: "card",
+    icon: "bar",
+  },
+  {
+    name: "Pulses",
+    filter: "pulse",
+    icon: "pulse",
+  },
+  {
+    name: "Metrics",
+    filter: "metric",
+    icon: "sum",
+  },
+  {
+    name: "Segments",
+    filter: "segment",
+    icon: "segment",
+  },
+];
+
+const TEST_ALL_ITEMS_TYPE = {
+  name: "All results",
+  filter: null,
+  icon: "search",
+};
+
+const TEST_ALL_TYPES = [TEST_ALL_ITEMS_TYPE, ...TEST_TYPES];
+
+const TestTypeSearchSidebarComponent = ({
+  initSelectedType = null,
+  onChange,
+}: {
+  initSelectedType: SearchModelType | null;
+  onChange: jest.Mock;
+}) => {
+  const [selectedType, onSelectType] = useState(initSelectedType);
+  onChange.mockImplementation(onSelectType);
+
+  return (
+    <TypeSearchSidebar
+      availableModels={TEST_TYPES.map(({ filter }) => filter)}
+      onSelectType={onChange}
+      selectedType={selectedType}
+    />
+  );
+};
+
+const setup = ({ initSelectedType = null } = {}) => {
+  const onChange = jest.fn();
+  render(
+    <TestTypeSearchSidebarComponent
+      initSelectedType={initSelectedType}
+      onChange={onChange}
+    />,
+  );
+
+  return { onChange };
+};
+
+describe("TypeSearchSidebar", () => {
+  it("display all available models with the correct text and icon for each type", () => {
+    setup();
+    const sidebar = within(screen.getByTestId("type-sidebar"));
+    TEST_ALL_TYPES.forEach(({ name, icon }) => {
+      expect(sidebar.getByText(name)).toBeInTheDocument();
+      expect(sidebar.getByLabelText(`${icon} icon`)).toBeInTheDocument();
+    });
+  });
+
+  it("should select the correct type when clicking on it", () => {
+    const { onChange } = setup();
+    const sidebar = within(screen.getByTestId("type-sidebar"));
+    TEST_TYPES.forEach(({ name, filter }) => {
+      sidebar.getByText(name).click();
+      expect(onChange).toHaveBeenCalledWith(filter);
+    });
+
+    sidebar.getByText(TEST_ALL_ITEMS_TYPE.name).click();
+    expect(onChange).toHaveBeenCalledWith(null);
+  });
+});
diff --git a/frontend/src/metabase/search/components/TypeSearchSidebar/index.ts b/frontend/src/metabase/search/components/TypeSearchSidebar/index.ts
new file mode 100644
index 00000000000..85f1d4b0182
--- /dev/null
+++ b/frontend/src/metabase/search/components/TypeSearchSidebar/index.ts
@@ -0,0 +1,2 @@
+export * from "./TypeSearchSidebar";
+export { SEARCH_FILTERS } from "metabase/search/constants";
diff --git a/frontend/src/metabase/search/components/types.ts b/frontend/src/metabase/search/components/types.ts
deleted file mode 100644
index 4ed0da16b12..00000000000
--- a/frontend/src/metabase/search/components/types.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-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/constants.ts b/frontend/src/metabase/search/constants.ts
new file mode 100644
index 00000000000..8176cb78a9e
--- /dev/null
+++ b/frontend/src/metabase/search/constants.ts
@@ -0,0 +1,61 @@
+import { t } from "ttag";
+import { IconName } from "metabase/core/components/Icon";
+import { SearchModelType } from "metabase-types/api";
+
+export const SearchFilterKeys = {
+  Type: "type",
+} as const;
+
+export const SEARCH_FILTERS: {
+  name: string;
+  icon: IconName;
+  filter: SearchModelType;
+}[] = [
+  {
+    name: t`Dashboards`,
+    filter: "dashboard",
+    icon: "dashboard",
+  },
+  {
+    name: t`Collections`,
+    filter: "collection",
+    icon: "folder",
+  },
+  {
+    name: t`Databases`,
+    filter: "database",
+    icon: "database",
+  },
+  {
+    name: t`Models`,
+    filter: "dataset",
+    icon: "model",
+  },
+  {
+    name: t`Raw Tables`,
+    filter: "table",
+    icon: "table",
+  },
+  {
+    name: t`Questions`,
+    filter: "card",
+    icon: "bar",
+  },
+  {
+    name: t`Pulses`,
+    filter: "pulse",
+    icon: "pulse",
+  },
+  {
+    name: t`Metrics`,
+    filter: "metric",
+    icon: "sum",
+  },
+  {
+    name: t`Segments`,
+    filter: "segment",
+    icon: "segment",
+  },
+];
+
+export const enabledSearchTypes = SEARCH_FILTERS.map(({ filter }) => filter);
diff --git a/frontend/src/metabase/search/containers/SearchApp.jsx b/frontend/src/metabase/search/containers/SearchApp.jsx
index 4c6737454ad..a59e94cf9d6 100644
--- a/frontend/src/metabase/search/containers/SearchApp.jsx
+++ b/frontend/src/metabase/search/containers/SearchApp.jsx
@@ -1,21 +1,27 @@
-import { Fragment, useState } from "react";
+import { useEffect, useMemo, useState } from "react";
 import PropTypes from "prop-types";
-import cx from "classnames";
 
 import { jt, t } from "ttag";
-import Link from "metabase/core/components/Link";
 
+import _ from "underscore";
 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 Subhead from "metabase/components/type/Subhead";
+import { Flex } from "metabase/ui";
 
-import { Icon } from "metabase/core/components/Icon";
 import NoResults from "assets/img/no_results.svg";
 import PaginationControls from "metabase/components/PaginationControls";
 import { usePagination } from "metabase/hooks/use-pagination";
+import {
+  getFiltersFromLocation,
+  getSearchTextFromLocation,
+} from "metabase/search/utils";
+import { TypeSearchSidebar } from "metabase/search/components/TypeSearchSidebar";
+import { PAGE_SIZE } from "metabase/search/containers/constants";
+import { SearchResult } from "metabase/search/components/SearchResult";
+import { SearchFilterKeys } from "metabase/search/constants";
 import {
   SearchBody,
   SearchControls,
@@ -25,172 +31,94 @@ import {
   SearchRoot,
 } from "./SearchApp.styled";
 
-const PAGE_SIZE = 50;
-
-const SEARCH_FILTERS = [
-  {
-    name: t`Apps`,
-    filter: "app",
-    icon: "star",
-  },
-  {
-    name: t`Dashboards`,
-    filter: "dashboard",
-    icon: "dashboard",
-  },
-  {
-    name: t`Collections`,
-    filter: "collection",
-    icon: "folder",
-  },
-  {
-    name: t`Databases`,
-    filter: "database",
-    icon: "database",
-  },
-  {
-    name: t`Models`,
-    filter: "dataset",
-    icon: "model",
-  },
-  {
-    name: t`Raw Tables`,
-    filter: "table",
-    icon: "table",
-  },
-  {
-    name: t`Questions`,
-    filter: "card",
-    icon: "bar",
-  },
-  {
-    name: t`Pulses`,
-    filter: "pulse",
-    icon: "pulse",
-  },
-  {
-    name: t`Metrics`,
-    filter: "metric",
-    icon: "sum",
-  },
-  {
-    name: t`Segments`,
-    filter: "segment",
-    icon: "segment",
-  },
-];
-
 export default function SearchApp({ location }) {
   const { handleNextPage, handlePreviousPage, setPage, page } = usePagination();
-  const [filter, setFilter] = useState(location.query.type);
 
-  const handleFilterChange = filterItem => {
-    setFilter(filterItem && filterItem.filter);
-    setPage(0);
-  };
+  const searchText = useMemo(
+    () => getSearchTextFromLocation(location),
+    [location],
+  );
+
+  const searchFilters = useMemo(() => {
+    return getFiltersFromLocation(location);
+  }, [location]);
+
+  const [selectedSidebarType, setSelectedSidebarType] = useState(null);
+
+  useEffect(() => {
+    if (location.search) {
+      setSelectedSidebarType(null);
+    }
+  }, [location.search]);
 
   const query = {
-    q: location.query.q,
+    q: searchText,
+    ..._.omit(searchFilters, SearchFilterKeys.Type),
+    models: selectedSidebarType ?? searchFilters[SearchFilterKeys.Type],
     limit: PAGE_SIZE,
     offset: PAGE_SIZE * page,
   };
 
-  if (filter) {
-    query.models = filter;
-  }
+  const onChangeSelectedType = filter => {
+    setSelectedSidebarType(filter);
+    setPage(0);
+  };
+
+  const getAvailableModels = availableModels => {
+    const models = availableModels || [];
+    return models.filter(
+      filter => !searchFilters?.type || searchFilters.type.includes(filter),
+    );
+  };
 
   return (
-    <SearchRoot>
-      {location.query.q && (
+    <SearchRoot data-testid="search-app">
+      {searchText && (
         <SearchHeader>
-          <Subhead>{jt`Results for "${location.query.q}"`}</Subhead>
+          <Subhead>{jt`Results for "${searchText}"`}</Subhead>
         </SearchHeader>
       )}
-      <div>
-        <Search.ListLoader query={query} wrapped>
-          {({ list, metadata }) => {
-            if (list.length === 0) {
-              return (
-                <SearchEmptyState>
-                  <Card>
-                    <EmptyState
-                      title={t`Didn't find anything`}
-                      message={t`There weren't any results for your search.`}
-                      illustrationElement={<img src={NoResults} />}
-                    />
-                  </Card>
-                </SearchEmptyState>
-              );
-            }
-
-            const availableModels = metadata.available_models || [];
-
-            const filters = SEARCH_FILTERS.filter(f =>
-              availableModels.includes(f.filter),
-            );
-
-            return (
-              <SearchBody>
-                <SearchMain>
-                  <Fragment>
-                    <SearchResultSection items={list} />
-                    <div className="flex justify-end my2">
-                      <PaginationControls
-                        showTotal
-                        pageSize={PAGE_SIZE}
-                        page={page}
-                        itemsLength={list.length}
-                        total={metadata.total}
-                        onNextPage={handleNextPage}
-                        onPreviousPage={handlePreviousPage}
-                      />
-                    </div>
-                  </Fragment>
-                </SearchMain>
-                <SearchControls>
-                  {filters.length > 0 ? (
-                    <Link
-                      className={cx("flex align-center mb3", {
-                        "text-brand": filter == null,
-                        "text-inherit": filter != null,
-                      })}
-                      onClick={() => handleFilterChange(null)}
-                      to={{
-                        pathname: location.pathname,
-                        query: { ...location.query, type: undefined },
-                      }}
-                    >
-                      <Icon name="search" className="mr1" />
-                      <h4>{t`All results`}</h4>
-                    </Link>
-                  ) : null}
-                  {filters.map(f => {
-                    const isActive = filter === f.filter;
-
-                    return (
-                      <Link
-                        key={f.filter}
-                        className={cx("mb3 flex align-center", {
-                          "text-brand": isActive,
-                          "text-medium": !isActive,
-                        })}
-                        onClick={() => handleFilterChange(f)}
-                        to={{
-                          pathname: location.pathname,
-                          query: { ...location.query, type: f.filter },
-                        }}
-                      >
-                        <Icon className="mr1" name={f.icon} size={16} />
-                        <h4>{f.name}</h4>
-                      </Link>
-                    );
-                  })}
-                </SearchControls>
-              </SearchBody>
-            );
-          }}
-        </Search.ListLoader>
-      </div>
+      <Search.ListLoader query={query} wrapped>
+        {({ list, metadata }) =>
+          list.length > 0 ? (
+            <SearchBody>
+              <SearchMain>
+                <SearchResultSection items={list} />
+                <Flex justify="flex-end" align="center" my="1rem">
+                  <PaginationControls
+                    showTotal
+                    pageSize={PAGE_SIZE}
+                    page={page}
+                    itemsLength={list.length}
+                    total={metadata.total}
+                    onNextPage={handleNextPage}
+                    onPreviousPage={handlePreviousPage}
+                  />
+                </Flex>
+              </SearchMain>
+              <SearchControls>
+                <TypeSearchSidebar
+                  availableModels={getAvailableModels(
+                    metadata.available_models,
+                  )}
+                  selectedType={selectedSidebarType}
+                  onSelectType={onChangeSelectedType}
+                />
+              </SearchControls>
+            </SearchBody>
+          ) : (
+            <SearchEmptyState>
+              <Card>
+                <EmptyState
+                  title={t`Didn't find anything`}
+                  message={t`There weren't any results for your search.`}
+                  illustrationElement={<img src={NoResults} />}
+                />
+              </Card>
+            </SearchEmptyState>
+          )
+        }
+      </Search.ListLoader>
     </SearchRoot>
   );
 }
diff --git a/frontend/src/metabase/search/containers/SearchApp.styled.tsx b/frontend/src/metabase/search/containers/SearchApp.styled.tsx
index 933c1efb21f..c93501632d8 100644
--- a/frontend/src/metabase/search/containers/SearchApp.styled.tsx
+++ b/frontend/src/metabase/search/containers/SearchApp.styled.tsx
@@ -39,7 +39,7 @@ export const SearchMain = styled.div`
 `;
 
 export const SearchControls = styled.div`
-  padding: 1rem 1rem 0 1rem;
+  padding: 0 1rem 0 1rem;
   margin-left: 0.5rem;
 
   ${breakpointMinSmall} {
diff --git a/frontend/src/metabase/search/containers/SearchApp.unit.spec.tsx b/frontend/src/metabase/search/containers/SearchApp.unit.spec.tsx
new file mode 100644
index 00000000000..e150dca676b
--- /dev/null
+++ b/frontend/src/metabase/search/containers/SearchApp.unit.spec.tsx
@@ -0,0 +1,216 @@
+import userEvent from "@testing-library/user-event";
+import { renderWithProviders, screen, waitFor } from "__support__/ui";
+import SearchApp from "metabase/search/containers/SearchApp";
+import { Route } from "metabase/hoc/Title";
+import {
+  setupDatabasesEndpoints,
+  setupSearchEndpoints,
+  setupTableEndpoints,
+} from "__support__/server-mocks";
+import {
+  createMockDatabase,
+  createMockSearchResult,
+  createMockTable,
+} from "metabase-types/api/mocks";
+import { SearchResult } from "metabase-types/api";
+
+import { SearchFilters } from "metabase/search/types";
+import { checkNotNull } from "metabase/core/utils/types";
+
+// Mock PAGE_SIZE so we don't have to generate a ton of elements for the pagination test
+jest.mock("metabase/search/containers/constants", () => ({
+  PAGE_SIZE: 3,
+}));
+
+const ALL_RESULTS_SIDEBAR_NAME = "All results";
+
+const SIDEBAR_NAMES: Record<string, string> = {
+  collection: "Collections",
+  dashboard: "Dashboards",
+  database: "Databases",
+  dataset: "Models",
+  metric: "Metrics",
+  pulse: "Pulses",
+  segment: "Segments",
+  table: "Raw Tables",
+  card: "Questions",
+};
+
+const TEST_ITEMS: Partial<SearchResult>[] = [
+  { name: "Test Card", model: "card" },
+  { name: "Test Collection", model: "collection" },
+  { name: "Test Dashboard", model: "dashboard" },
+  { name: "Test Database", model: "database" },
+  { name: "Test Dataset", model: "dataset" },
+  { name: "Test Table", model: "table" },
+  { name: "Test Pulse", model: "pulse" },
+  { name: "Test Segment", model: "segment" },
+  { name: "Test Metric", model: "metric" },
+];
+
+const TEST_SEARCH_RESULTS: SearchResult[] = TEST_ITEMS.map((metadata, index) =>
+  createMockSearchResult({ ...metadata, id: index + 1 }),
+);
+
+const TEST_DATABASE = createMockDatabase();
+const TEST_TABLE = createMockTable();
+
+const setup = async ({
+  searchText,
+  searchFilters = {},
+  searchItems = TEST_SEARCH_RESULTS,
+}: {
+  searchText: string;
+  searchFilters?: SearchFilters;
+  searchItems?: SearchResult[];
+}) => {
+  setupDatabasesEndpoints([TEST_DATABASE]);
+  setupSearchEndpoints(searchItems);
+  setupTableEndpoints(TEST_TABLE);
+
+  // for testing the hydration of search text and filters on page load
+  const params = {
+    ...searchFilters,
+    q: searchText,
+  };
+
+  const searchParams = new URLSearchParams(
+    params as unknown as Record<string, string>,
+  ).toString();
+
+  const initialRoute = searchParams ? `/search?${searchParams}` : `/search`;
+
+  const { history } = renderWithProviders(
+    <Route path="search" component={SearchApp} />,
+    {
+      withRouter: true,
+      initialRoute,
+    },
+  );
+
+  await waitFor(() => {
+    expect(screen.queryByTestId("loading-spinner")).not.toBeInTheDocument();
+  });
+
+  return {
+    history: checkNotNull(history),
+  };
+};
+
+describe("SearchApp", () => {
+  describe("rendering search results and pagination", () => {
+    it("renders empty state when there are no search results", async () => {
+      // let's pick some text that doesn't ever match anything
+      await setup({ searchText: "oisin" });
+
+      expect(screen.getByText('Results for "oisin"')).toBeInTheDocument();
+      expect(
+        screen.getByText("There weren't any results for your search."),
+      ).toBeInTheDocument();
+      expect(screen.queryByLabelText("pagination")).not.toBeInTheDocument();
+    });
+
+    it("renders search results when there is at least one result", async () => {
+      await setup({ searchText: "Card" });
+
+      const searchResultsHeader = screen.getByText('Results for "Card"');
+      expect(searchResultsHeader).toBeInTheDocument();
+
+      const searchResults = screen.getAllByTestId("search-result-item");
+      expect(searchResults.length).toEqual(1);
+      expect(screen.queryByLabelText("pagination")).not.toBeInTheDocument();
+    });
+
+    it("renders search results and pagination when there is more than PAGE_SIZE results", async () => {
+      await setup({ searchText: "a" });
+      const getPaginationTotal = () => screen.getByTestId("pagination-total");
+      const getPagination = () => screen.getByLabelText("pagination");
+      const getNextPageButton = () => screen.getByTestId("next-page-btn");
+      const getPreviousPageButton = () =>
+        screen.getByTestId("previous-page-btn");
+
+      expect(getPaginationTotal()).toHaveTextContent("5");
+      expect(getPreviousPageButton()).toBeDisabled();
+      expect(getNextPageButton()).toBeEnabled();
+      expect(getPagination()).toHaveTextContent("1 - 3");
+
+      // test next page button
+      userEvent.click(getNextPageButton());
+      await waitFor(() => {
+        expect(screen.queryByTestId("loading-spinner")).not.toBeInTheDocument();
+      });
+      expect(getPaginationTotal()).toHaveTextContent("5");
+      expect(getPreviousPageButton()).toBeEnabled();
+      expect(getNextPageButton()).toBeDisabled();
+
+      expect(getPagination()).toHaveTextContent("4 - 5");
+
+      // test previous page button
+      userEvent.click(getPreviousPageButton());
+      await waitFor(() => {
+        expect(screen.queryByTestId("loading-spinner")).not.toBeInTheDocument();
+      });
+      expect(getPaginationTotal()).toHaveTextContent("5");
+      expect(getPreviousPageButton()).toBeDisabled();
+      expect(getNextPageButton()).toBeEnabled();
+      expect(getPagination()).toHaveTextContent("1 - 3");
+    });
+  });
+
+  describe("filtering search results with the sidebar", () => {
+    it.each(TEST_SEARCH_RESULTS)(
+      "should reload with filtered searches when type=$model on the right sidebar is clicked without changing URL",
+      async ({ model, name }) => {
+        const { history } = await setup({
+          searchText: "Test",
+        });
+
+        let url = history.getCurrentLocation();
+        const { pathname: prevPathname, search: prevSearch } = url ?? {};
+
+        const sidebarItems = screen.getAllByTestId("type-sidebar-item");
+        expect(sidebarItems).toHaveLength(10);
+        expect(sidebarItems[0]).toHaveTextContent(ALL_RESULTS_SIDEBAR_NAME);
+
+        const sidebarItem = screen.getByText(SIDEBAR_NAMES[model]);
+        userEvent.click(sidebarItem);
+        url = history.getCurrentLocation();
+        const { pathname, search } = url ?? {};
+        expect(pathname).toEqual(prevPathname);
+        expect(search).toEqual(prevSearch);
+
+        const searchResultItem = await screen.findByTestId(
+          "search-result-item",
+        );
+        expect(searchResultItem).toBeInTheDocument();
+        expect(searchResultItem).toHaveTextContent(name);
+      },
+    );
+  });
+
+  describe("hydrating search filters from URL", () => {
+    // TODO: Add tests for other filters as they come
+
+    it.each(TEST_SEARCH_RESULTS)(
+      "should filter by type = $name",
+      async ({ name, model }) => {
+        await setup({
+          searchText: name,
+          searchFilters: { type: [model] },
+        });
+
+        expect(screen.getByText(`Results for "${name}"`)).toBeInTheDocument();
+        expect(screen.getByTestId("search-result-item")).toBeInTheDocument();
+        expect(screen.getByTestId("search-result-item-name")).toHaveTextContent(
+          name,
+        );
+
+        const sidebarItems = screen.getAllByTestId("type-sidebar-item");
+
+        expect(sidebarItems).toHaveLength(2);
+        expect(sidebarItems[0]).toHaveTextContent(ALL_RESULTS_SIDEBAR_NAME);
+        expect(sidebarItems[1]).toHaveTextContent(SIDEBAR_NAMES[model]);
+      },
+    );
+  });
+});
diff --git a/frontend/src/metabase/search/containers/constants.ts b/frontend/src/metabase/search/containers/constants.ts
new file mode 100644
index 00000000000..c0b65ab6b8e
--- /dev/null
+++ b/frontend/src/metabase/search/containers/constants.ts
@@ -0,0 +1 @@
+export const PAGE_SIZE = 50;
diff --git a/frontend/src/metabase/search/types.ts b/frontend/src/metabase/search/types.ts
new file mode 100644
index 00000000000..b6b0a09ff44
--- /dev/null
+++ b/frontend/src/metabase/search/types.ts
@@ -0,0 +1,36 @@
+import { Location } from "history";
+import { FC } from "react";
+import { Collection, SearchModelType, SearchResult } from "metabase-types/api";
+import { IconName } from "metabase/core/components/Icon";
+import { SearchFilterKeys } from "metabase/search/constants";
+
+export interface WrappedResult extends SearchResult {
+  getUrl: () => string;
+  getIcon: () => {
+    name: IconName;
+    size?: number;
+    width?: number;
+    height?: number;
+  };
+  getCollection: () => Partial<Collection>;
+}
+
+export type TypeFilterProps = SearchModelType[];
+
+export type SearchFilterPropTypes = {
+  [SearchFilterKeys.Type]: TypeFilterProps;
+};
+
+export type FilterTypeKeys = keyof SearchFilterPropTypes;
+
+export type SearchFilters = Partial<SearchFilterPropTypes>;
+
+export type SearchFilterComponent<T extends FilterTypeKeys = any> = FC<
+  {
+    value?: SearchFilterPropTypes[T];
+    onChange: (value: SearchFilterPropTypes[T]) => void;
+    "data-testid"?: string;
+  } & Record<string, unknown>
+>;
+
+export type SearchAwareLocation = Location<{ q?: string } & SearchFilters>;
diff --git a/frontend/src/metabase/search/utils/index.ts b/frontend/src/metabase/search/utils/index.ts
new file mode 100644
index 00000000000..4871119ecd3
--- /dev/null
+++ b/frontend/src/metabase/search/utils/index.ts
@@ -0,0 +1 @@
+export * from "./search-location";
diff --git a/frontend/src/metabase/search/utils/search-location/index.ts b/frontend/src/metabase/search/utils/search-location/index.ts
new file mode 100644
index 00000000000..4871119ecd3
--- /dev/null
+++ b/frontend/src/metabase/search/utils/search-location/index.ts
@@ -0,0 +1 @@
+export * from "./search-location";
diff --git a/frontend/src/metabase/search/utils/search-location/search-location.ts b/frontend/src/metabase/search/utils/search-location/search-location.ts
new file mode 100644
index 00000000000..a98c8c17e31
--- /dev/null
+++ b/frontend/src/metabase/search/utils/search-location/search-location.ts
@@ -0,0 +1,27 @@
+import _ from "underscore";
+
+import { SearchAwareLocation, SearchFilters } from "metabase/search/types";
+import { SearchFilterKeys } from "metabase/search/constants";
+
+export function isSearchPageLocation(location: SearchAwareLocation): boolean {
+  const components = location.pathname.split("/");
+  return components[components.length - 1] === "search";
+}
+
+export function getSearchTextFromLocation(
+  location: SearchAwareLocation,
+): string {
+  if (isSearchPageLocation(location)) {
+    return location.query.q || "";
+  }
+  return "";
+}
+
+export function getFiltersFromLocation(
+  location: SearchAwareLocation,
+): SearchFilters {
+  if (isSearchPageLocation(location)) {
+    return _.pick(location.query, Object.values(SearchFilterKeys));
+  }
+  return {};
+}
diff --git a/frontend/src/metabase/search/utils/search-location/search-location.unit.spec.ts b/frontend/src/metabase/search/utils/search-location/search-location.unit.spec.ts
new file mode 100644
index 00000000000..7f0f545ee96
--- /dev/null
+++ b/frontend/src/metabase/search/utils/search-location/search-location.unit.spec.ts
@@ -0,0 +1,87 @@
+import {
+  getFiltersFromLocation,
+  getSearchTextFromLocation,
+  isSearchPageLocation,
+} from "metabase/search/utils";
+
+import { SearchAwareLocation } from "metabase/search/types";
+import { SearchFilterKeys } from "metabase/search/constants";
+
+describe("isSearchPageLocation", () => {
+  it('should return true when the last component of pathname is "search"', () => {
+    const location = {
+      pathname: "/search",
+      query: {},
+    };
+    expect(isSearchPageLocation(location as SearchAwareLocation)).toBe(true);
+  });
+
+  it('should return false when the last component of pathname is not "search"', () => {
+    const location = {
+      pathname: "/collection/root",
+      query: {},
+    };
+    expect(isSearchPageLocation(location as SearchAwareLocation)).toBe(false);
+  });
+});
+
+describe("getSearchTextFromLocation", () => {
+  it("should return the search text when on the search page", () => {
+    const location = {
+      pathname: "/search",
+      query: { q: "test" },
+    };
+    expect(getSearchTextFromLocation(location as SearchAwareLocation)).toBe(
+      "test",
+    );
+  });
+
+  it("should return an empty string when not on the search page", () => {
+    const location = {
+      pathname: "/collection/root",
+      query: {
+        q: "test",
+      },
+    };
+    expect(getSearchTextFromLocation(location as SearchAwareLocation)).toBe("");
+  });
+});
+
+describe("getFiltersFromLocation", () => {
+  it("should return the filters when on the search page", () => {
+    const location = {
+      pathname: "/search",
+      query: {
+        [SearchFilterKeys.Type]: ["app", "database"],
+      },
+    };
+    expect(getFiltersFromLocation(location as SearchAwareLocation)).toEqual({
+      [SearchFilterKeys.Type]: ["app", "database"],
+    });
+  });
+
+  it("should return an empty object when on a non-search page", () => {
+    const location = {
+      pathname: "/collection/root",
+      query: {
+        [SearchFilterKeys.Type]: ["app", "database"],
+      },
+    };
+    expect(getFiltersFromLocation(location as SearchAwareLocation)).toEqual({});
+  });
+
+  it("should return only the filters that exist in SearchFilterKeys", () => {
+    const location = {
+      pathname: "/search",
+      query: {
+        [SearchFilterKeys.Type]: ["app", "database"],
+        someOtherFilter: [1, 2, 3],
+      },
+    };
+    // using `any` here since location.query doesn't match the query
+    // of SearchAwareLocation
+    expect(getFiltersFromLocation(location as any)).toEqual({
+      [SearchFilterKeys.Type]: ["app", "database"],
+    });
+  });
+});
diff --git a/frontend/test/__support__/server-mocks/search.ts b/frontend/test/__support__/server-mocks/search.ts
index 8c82cf00ff2..4faa5971983 100644
--- a/frontend/test/__support__/server-mocks/search.ts
+++ b/frontend/test/__support__/server-mocks/search.ts
@@ -1,21 +1,32 @@
 import fetchMock from "fetch-mock";
-import type { CollectionItem } from "metabase-types/api";
-
-export function setupSearchEndpoints(items: CollectionItem[]) {
-  const availableModels = items.map(({ model }) => model);
+import type { CollectionItem, SearchResult } from "metabase-types/api";
 
+export function setupSearchEndpoints(items: (CollectionItem | SearchResult)[]) {
   fetchMock.get("path:/api/search", uri => {
     const url = new URL(uri);
     const models = url.searchParams.getAll("models");
-    const limit = Number(url.searchParams.get("limit"));
+    const limit = Number(url.searchParams.get("limit")) || 50;
     const offset = Number(url.searchParams.get("offset"));
     const table_db_id = url.searchParams.get("table_db_id") || null;
-    const matchedItems = items.filter(({ model }) => models.includes(model));
+    const queryText = url.searchParams.get("q")?.toLowerCase();
+
+    let matchedItems = items.filter(
+      ({ name }) =>
+        !queryText || name.toLowerCase().includes(queryText.toLowerCase()),
+    );
+
+    const availableModels = [
+      ...new Set(matchedItems.map(({ model }) => model)),
+    ];
+
+    matchedItems = matchedItems.filter(
+      ({ model }) => !models.length || models.includes(model),
+    );
 
     return {
-      data: matchedItems,
+      data: matchedItems.slice(offset, offset + limit),
       total: matchedItems.length,
-      models, // this should reflect what is in the query param
+      models,
       available_models: availableModels,
       limit,
       offset,
-- 
GitLab