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