From d438e16d03d63f8a457c32ba0ba905d8dfd752a3 Mon Sep 17 00:00:00 2001 From: Alexander Polyankin <alexander.polyankin@metabase.com> Date: Tue, 21 Mar 2023 16:38:14 +0200 Subject: [PATCH] Fix data picker search (#29325) --- .../ActionDashcardSettings.unit.spec.tsx | 5 +- .../containers/DataPicker/DataPickerView.tsx | 1 - .../DataPicker/DataSearch/DataSearch.tsx | 11 +-- .../tests/DataPicker-Models.unit.spec.ts | 51 ++++++++++++ .../tests/DataPicker-Questions.unit.spec.ts | 51 +++++++++++- .../containers/DataPicker/tests/common.tsx | 77 +++++++++++++------ .../data-search/SearchResults.jsx | 4 +- .../test/__support__/server-mocks/search.ts | 38 +++++---- 8 files changed, 183 insertions(+), 55 deletions(-) diff --git a/frontend/src/metabase/actions/components/ActionViz/ActionDashcardSettings.unit.spec.tsx b/frontend/src/metabase/actions/components/ActionViz/ActionDashcardSettings.unit.spec.tsx index e69154bcd5e..bb20405c4cb 100644 --- a/frontend/src/metabase/actions/components/ActionViz/ActionDashcardSettings.unit.spec.tsx +++ b/frontend/src/metabase/actions/components/ActionViz/ActionDashcardSettings.unit.spec.tsx @@ -84,9 +84,12 @@ const setup = ( React.ComponentProps<typeof ConnectedActionDashcardSettings> >, ) => { + const searchItems = models.map(model => + createMockCollectionItem({ ...model, model: "dataset" }), + ); const closeSpy = jest.fn(); - setupSearchEndpoints(models.map(model => createMockCollectionItem(model))); + setupSearchEndpoints(searchItems); setupCardsEndpoints(models); setupActionsEndpoints([...actions1, ...actions2]); diff --git a/frontend/src/metabase/containers/DataPicker/DataPickerView.tsx b/frontend/src/metabase/containers/DataPicker/DataPickerView.tsx index 1a6267c6862..a0b0e6fede2 100644 --- a/frontend/src/metabase/containers/DataPicker/DataPickerView.tsx +++ b/frontend/src/metabase/containers/DataPicker/DataPickerView.tsx @@ -48,7 +48,6 @@ function DataPickerViewContent({ if (searchQuery.trim().length > MIN_SEARCH_LENGTH) { return ( <DataSearch - value={value} searchQuery={searchQuery} availableDataTypes={availableDataTypes} onChange={onChange} diff --git a/frontend/src/metabase/containers/DataPicker/DataSearch/DataSearch.tsx b/frontend/src/metabase/containers/DataPicker/DataSearch/DataSearch.tsx index 8bc6ecda2a1..742f21ad8bb 100644 --- a/frontend/src/metabase/containers/DataPicker/DataSearch/DataSearch.tsx +++ b/frontend/src/metabase/containers/DataPicker/DataSearch/DataSearch.tsx @@ -16,7 +16,6 @@ import { useDataPicker } from "../DataPickerContext"; import type { DataPickerValue, DataPickerDataType } from "../types"; interface DataSearchProps { - value: DataPickerValue; searchQuery: string; availableDataTypes: DataPickerDataType[]; onChange: (value: DataPickerValue) => void; @@ -68,7 +67,7 @@ function getValueForVirtualTable(table: TableSearchResult): DataPickerValue { isDatasets: type === "models", }); return { - type: "models", + type, databaseId: SAVED_QUESTIONS_VIRTUAL_DB_ID, schemaId, collectionId: table.collection?.id || "root", @@ -85,7 +84,6 @@ function getNextValue(table: TableSearchResult): DataPickerValue { } function DataSearch({ - value, searchQuery, availableDataTypes, onChange, @@ -94,11 +92,8 @@ function DataSearch({ const { setQuery } = search; const searchModels: SearchModel[] = useMemo(() => { - if (!value.type) { - return availableDataTypes.map(type => DATA_TYPE_SEARCH_MODEL_MAP[type]); - } - return [DATA_TYPE_SEARCH_MODEL_MAP[value.type]]; - }, [value.type, availableDataTypes]); + return availableDataTypes.map(type => DATA_TYPE_SEARCH_MODEL_MAP[type]); + }, [availableDataTypes]); const onSelect = useCallback( (table: TableSearchResult) => { diff --git a/frontend/src/metabase/containers/DataPicker/tests/DataPicker-Models.unit.spec.ts b/frontend/src/metabase/containers/DataPicker/tests/DataPicker-Models.unit.spec.ts index 7beed8ee8d3..cffbd953010 100644 --- a/frontend/src/metabase/containers/DataPicker/tests/DataPicker-Models.unit.spec.ts +++ b/frontend/src/metabase/containers/DataPicker/tests/DataPicker-Models.unit.spec.ts @@ -17,6 +17,7 @@ import { SAMPLE_MODEL, SAMPLE_MODEL_2, SAMPLE_MODEL_3, + SAMPLE_QUESTION, } from "./common"; const ROOT_COLLECTION_MODEL_VIRTUAL_SCHEMA_ID = getCollectionVirtualSchemaId( @@ -189,4 +190,54 @@ describe("DataPicker — picking models", () => { screen.queryByRole("button", { name: /Back/i }), ).not.toBeInTheDocument(); }); + + it("should be able to search for a model", async () => { + const { onChange } = await setup({ + filters: { + types: type => type === "models", + }, + }); + + userEvent.type(screen.getByRole("textbox"), SAMPLE_MODEL.name); + expect(await screen.findByText(SAMPLE_MODEL.name)).toBeInTheDocument(); + expect(screen.queryByText(SAMPLE_QUESTION.name)).not.toBeInTheDocument(); + + userEvent.click(screen.getByText(SAMPLE_MODEL.name)); + expect(onChange).toHaveBeenLastCalledWith({ + type: "models", + databaseId: SAVED_QUESTIONS_VIRTUAL_DB_ID, + schemaId: getCollectionVirtualSchemaId(SAMPLE_COLLECTION, { + isDatasets: true, + }), + collectionId: SAMPLE_MODEL.collection_id, + tableIds: [getQuestionVirtualTableId(SAMPLE_MODEL.id)], + }); + }); + + it("should be able to search for a model when a question was selected", async () => { + const { onChange } = await setup({ + initialValue: { + type: "questions", + databaseId: SAVED_QUESTIONS_VIRTUAL_DB_ID, + schemaId: getCollectionVirtualSchemaId(SAMPLE_COLLECTION), + collectionId: "root", + tableIds: [getQuestionVirtualTableId(SAMPLE_QUESTION.id)], + }, + }); + + userEvent.type(screen.getByRole("textbox"), "Sample"); + expect(await screen.findByText(SAMPLE_MODEL.name)).toBeInTheDocument(); + expect(screen.getByText(SAMPLE_QUESTION.name)).toBeInTheDocument(); + + userEvent.click(screen.getByText(SAMPLE_MODEL.name)); + expect(onChange).toHaveBeenLastCalledWith({ + type: "models", + databaseId: SAVED_QUESTIONS_VIRTUAL_DB_ID, + schemaId: getCollectionVirtualSchemaId(SAMPLE_COLLECTION, { + isDatasets: true, + }), + collectionId: SAMPLE_MODEL.collection_id, + tableIds: [getQuestionVirtualTableId(SAMPLE_MODEL.id)], + }); + }); }); diff --git a/frontend/src/metabase/containers/DataPicker/tests/DataPicker-Questions.unit.spec.ts b/frontend/src/metabase/containers/DataPicker/tests/DataPicker-Questions.unit.spec.ts index a3c8f97aaa3..67b506f1fa6 100644 --- a/frontend/src/metabase/containers/DataPicker/tests/DataPicker-Questions.unit.spec.ts +++ b/frontend/src/metabase/containers/DataPicker/tests/DataPicker-Questions.unit.spec.ts @@ -11,12 +11,13 @@ import { } from "metabase-lib/metadata/utils/saved-questions"; import { - setup, EMPTY_COLLECTION, SAMPLE_COLLECTION, + SAMPLE_MODEL, SAMPLE_QUESTION, SAMPLE_QUESTION_2, SAMPLE_QUESTION_3, + setup, } from "./common"; const ROOT_COLLECTION_QUESTIONS_VIRTUAL_SCHEMA_ID = @@ -167,4 +168,52 @@ describe("DataPicker — picking questions", () => { tableIds: [], }); }); + + it("should be able to search for a question", async () => { + const { onChange } = await setup({ + filters: { + types: type => type === "questions", + }, + }); + + userEvent.type(screen.getByRole("textbox"), SAMPLE_QUESTION.name); + expect(await screen.findByText(SAMPLE_QUESTION.name)).toBeInTheDocument(); + expect(screen.queryByText(SAMPLE_MODEL.name)).not.toBeInTheDocument(); + + userEvent.click(screen.getByText(SAMPLE_QUESTION.name)); + expect(onChange).toHaveBeenLastCalledWith({ + type: "questions", + databaseId: SAVED_QUESTIONS_VIRTUAL_DB_ID, + schemaId: getCollectionVirtualSchemaId(SAMPLE_COLLECTION), + collectionId: SAMPLE_QUESTION.collection_id, + tableIds: [getQuestionVirtualTableId(SAMPLE_QUESTION.id)], + }); + }); + + it("should be able to search for a question when a model was selected", async () => { + const { onChange } = await setup({ + initialValue: { + type: "questions", + databaseId: SAVED_QUESTIONS_VIRTUAL_DB_ID, + schemaId: getCollectionVirtualSchemaId(SAMPLE_COLLECTION, { + isDatasets: true, + }), + collectionId: "root", + tableIds: [getQuestionVirtualTableId(SAMPLE_MODEL.id)], + }, + }); + + userEvent.type(screen.getByRole("textbox"), "Sample"); + expect(await screen.findByText(SAMPLE_QUESTION.name)).toBeInTheDocument(); + expect(screen.getByText(SAMPLE_MODEL.name)).toBeInTheDocument(); + + userEvent.click(screen.getByText(SAMPLE_QUESTION.name)); + expect(onChange).toHaveBeenLastCalledWith({ + type: "questions", + databaseId: SAVED_QUESTIONS_VIRTUAL_DB_ID, + schemaId: getCollectionVirtualSchemaId(SAMPLE_COLLECTION), + collectionId: SAMPLE_QUESTION.collection_id, + tableIds: [getQuestionVirtualTableId(SAMPLE_QUESTION.id)], + }); + }); }); diff --git a/frontend/src/metabase/containers/DataPicker/tests/common.tsx b/frontend/src/metabase/containers/DataPicker/tests/common.tsx index 60e5273f55b..df4730c9c07 100644 --- a/frontend/src/metabase/containers/DataPicker/tests/common.tsx +++ b/frontend/src/metabase/containers/DataPicker/tests/common.tsx @@ -1,6 +1,5 @@ /* istanbul ignore file */ import React from "react"; -import fetchMock from "fetch-mock"; import { renderWithProviders, @@ -11,13 +10,16 @@ import { setupCollectionsEndpoints, setupCollectionVirtualSchemaEndpoints, setupDatabasesEndpoints, + setupSearchEndpoints, } from "__support__/server-mocks"; +import Input from "metabase/core/components/Input"; import { ROOT_COLLECTION } from "metabase/entities/collections"; import { createMockCard, createMockCollection, + createMockCollectionItem, createMockDatabase, createMockTable, } from "metabase-types/api/mocks"; @@ -25,6 +27,7 @@ import { createMockSettingsState } from "metabase-types/store/mocks"; import type { DataPickerValue, DataPickerFiltersProp } from "../types"; import useDataPickerValue from "../useDataPickerValue"; +import { useDataPicker } from "../../DataPicker"; import DataPicker from "../DataPickerContainer"; export const SAMPLE_TABLE = createMockTable({ @@ -68,15 +71,18 @@ export const EMPTY_DATABASE = createMockDatabase({ tables: [], }); +export const SAMPLE_COLLECTION_ID = 1; +export const EMPTY_COLLECTION_ID = 2; + export const SAMPLE_COLLECTION = createMockCollection({ - id: 1, + id: SAMPLE_COLLECTION_ID, name: "Sample Collection", location: "/", here: ["card", "dataset"], }); export const EMPTY_COLLECTION = createMockCollection({ - id: 2, + id: EMPTY_COLLECTION_ID, name: "Empty Collection", location: "/", here: [], @@ -87,33 +93,51 @@ export const SAMPLE_MODEL = createMockCard({ id: 1, name: "Sample Model", dataset: true, + collection_id: SAMPLE_COLLECTION_ID, }); export const SAMPLE_MODEL_2 = createMockCard({ id: 2, name: "Sample Model 2", dataset: true, + collection_id: SAMPLE_COLLECTION_ID, }); export const SAMPLE_MODEL_3 = createMockCard({ id: 3, name: "Sample Model 3", dataset: true, + collection_id: SAMPLE_COLLECTION_ID, }); export const SAMPLE_QUESTION = createMockCard({ id: 4, name: "Sample Saved Question", + collection_id: SAMPLE_COLLECTION_ID, }); export const SAMPLE_QUESTION_2 = createMockCard({ id: 5, name: "Sample Saved Question 2", + collection_id: SAMPLE_COLLECTION_ID, }); export const SAMPLE_QUESTION_3 = createMockCard({ id: 6, name: "Sample Saved Question 3", + collection_id: SAMPLE_COLLECTION_ID, +}); + +export const SAMPLE_QUESTION_SEARCH_ITEM = createMockCollectionItem({ + ...SAMPLE_QUESTION, + model: "card", + collection: SAMPLE_COLLECTION, +}); + +export const SAMPLE_MODEL_SEARCH_ITEM = createMockCollectionItem({ + ...SAMPLE_MODEL, + model: "dataset", + collection: SAMPLE_COLLECTION, }); function DataPickerWrapper({ @@ -129,18 +153,28 @@ function DataPickerWrapper({ }) { const [value, setValue] = useDataPickerValue(initialValue); return ( - <DataPicker - value={value} - filters={filters} - isMultiSelect={isMultiSelect} - onChange={(value: DataPickerValue) => { - setValue(value); - onChange(value); - }} - /> + <DataPicker.Provider> + <DataPickerSearchInput /> + <DataPicker + value={value} + filters={filters} + isMultiSelect={isMultiSelect} + onChange={(value: DataPickerValue) => { + setValue(value); + onChange(value); + }} + /> + </DataPicker.Provider> ); } +function DataPickerSearchInput() { + const { search } = useDataPicker(); + const { query, setQuery } = search; + + return <Input value={query} onChange={e => setQuery(e.target.value)} />; +} + interface SetupOpts { initialValue?: DataPickerValue; filters?: DataPickerFiltersProp; @@ -182,16 +216,6 @@ export async function setup({ setupDatabasesEndpoints([], { hasSavedQuestions: false }); } - fetchMock.get( - { - url: "path:/api/search", - query: { models: "dataset", limit: 1 }, - }, - { - data: hasModels ? [SAMPLE_MODEL] : [], - }, - ); - setupCollectionsEndpoints([SAMPLE_COLLECTION, EMPTY_COLLECTION]); setupCollectionVirtualSchemaEndpoints(createMockCollection(ROOT_COLLECTION), [ @@ -210,6 +234,15 @@ export async function setup({ setupCollectionVirtualSchemaEndpoints(EMPTY_COLLECTION, []); + if (hasModels) { + setupSearchEndpoints([ + SAMPLE_QUESTION_SEARCH_ITEM, + SAMPLE_MODEL_SEARCH_ITEM, + ]); + } else { + setupSearchEndpoints([SAMPLE_QUESTION_SEARCH_ITEM]); + } + const settings = createMockSettingsState({ "enable-nested-queries": hasNestedQueriesEnabled, }); diff --git a/frontend/src/metabase/query_builder/components/DataSelector/data-search/SearchResults.jsx b/frontend/src/metabase/query_builder/components/DataSelector/data-search/SearchResults.jsx index 1c7e816791b..2f89d417338 100644 --- a/frontend/src/metabase/query_builder/components/DataSelector/data-search/SearchResults.jsx +++ b/frontend/src/metabase/query_builder/components/DataSelector/data-search/SearchResults.jsx @@ -10,8 +10,8 @@ import Search from "metabase/entities/search"; const propTypes = { databaseId: PropTypes.string, - searchQuery: PropTypes.string.required, - onSelect: PropTypes.func.required, + searchQuery: PropTypes.string.isRequired, + onSelect: PropTypes.func.isRequired, searchModels: PropTypes.arrayOf( PropTypes.oneOf(["card", "dataset", "table"]), ), diff --git a/frontend/test/__support__/server-mocks/search.ts b/frontend/test/__support__/server-mocks/search.ts index 146d9e9170a..38c859a1d0c 100644 --- a/frontend/test/__support__/server-mocks/search.ts +++ b/frontend/test/__support__/server-mocks/search.ts @@ -1,24 +1,22 @@ import fetchMock from "fetch-mock"; -import type { SearchModelType, CollectionItem } from "metabase-types/api"; +import type { CollectionItem } from "metabase-types/api"; -export function setupSearchEndpoints( - items: CollectionItem[], - models: SearchModelType[] = [], -) { - fetchMock.get("path:/api/search", { - available_models: [ - "dashboard", - "card", - "dataset", - "collection", - "table", - "database", - ], - data: items, - total: items.length, - models, // this should reflect what is in the query param - limit: null, - offset: null, - table_db_id: null, +export function setupSearchEndpoints(items: CollectionItem[]) { + const availableModels = items.map(({ model }) => model); + + fetchMock.get("path:/api/search", uri => { + const url = new URL(uri); + const models = url.searchParams.getAll("models"); + const matchedItems = items.filter(({ model }) => models.includes(model)); + + return { + data: matchedItems, + total: matchedItems.length, + models, // this should reflect what is in the query param + available_models: availableModels, + limit: null, + offset: null, + table_db_id: null, + }; }); } -- GitLab