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