diff --git a/enterprise/frontend/src/metabase-enterprise/collections/index.ts b/enterprise/frontend/src/metabase-enterprise/collections/index.ts index 8ae1785b122851c84869ae131ea6d1e626119959..f17d6e7b0daafc4da60e90bee564878f7fde4785 100644 --- a/enterprise/frontend/src/metabase-enterprise/collections/index.ts +++ b/enterprise/frontend/src/metabase-enterprise/collections/index.ts @@ -30,6 +30,8 @@ if (hasPremiumFeature("official_collections")) { PLUGIN_COLLECTIONS.AUTHORITY_LEVEL = AUTHORITY_LEVELS; + PLUGIN_COLLECTIONS.getIcon = getIcon; + PLUGIN_COLLECTIONS.getAuthorityLevelMenuItems = ( collection: Collection, onUpdate: (collection: Collection, values: Partial<Collection>) => void, @@ -77,5 +79,4 @@ if (hasPremiumFeature("audit_app")) { CUSTOM_INSTANCE_ANALYTICS_COLLECTION_ENTITY_ID; PLUGIN_COLLECTIONS.INSTANCE_ANALYTICS_ADMIN_READONLY_MESSAGE = t`This instance analytics collection is read-only for admin users`; - PLUGIN_COLLECTIONS.getIcon = getIcon; } diff --git a/enterprise/frontend/src/metabase-enterprise/collections/utils.ts b/enterprise/frontend/src/metabase-enterprise/collections/utils.ts index 4c09a1fb257b0c11efeb1e9cd8c06727954c66dc..cb901402d1f25de44e961b5b214f66c5cd52b5ca 100644 --- a/enterprise/frontend/src/metabase-enterprise/collections/utils.ts +++ b/enterprise/frontend/src/metabase-enterprise/collections/utils.ts @@ -52,14 +52,18 @@ export const getIcon = (item: ObjectWithModel): IconData => { }; } - if (item.model === "collection" && item.authority_level === "official") { + if ( + item.model === "collection" && + (item.authority_level === "official" || + item.collection_authority_level === "official") + ) { return { name: OFFICIAL_COLLECTION.icon, color: OFFICIAL_COLLECTION.color, }; } - if (item.model === "dataset" && item.authority_level === "official") { + if (item.model === "dataset" && item.moderated_status === "verified") { return { name: "model_with_badge", color: OFFICIAL_COLLECTION.color, diff --git a/enterprise/frontend/src/metabase-enterprise/collections/utils.unit.spec.ts b/enterprise/frontend/src/metabase-enterprise/collections/utils.unit.spec.ts index 54c5ab71db95f2b35b7ecfd88a683f87f045a7eb..e8c6289a8155f999a5f506407d6a555f9f2d9baf 100644 --- a/enterprise/frontend/src/metabase-enterprise/collections/utils.unit.spec.ts +++ b/enterprise/frontend/src/metabase-enterprise/collections/utils.unit.spec.ts @@ -76,9 +76,18 @@ describe("Collections plugin utils", () => { ).toEqual({ name: "badge", color: "saturated-yellow" }); }); - it("should return the correct icon for an official dataset", () => { + it("official collection in search", () => { + const collection = { + id: 101, + collection_authority_level: "official", + model: "collection" as const, + }; + expect(getIcon(collection).name).toBe("badge"); + }); + + it("should return the correct icon for an official model", () => { expect( - getIcon({ model: "dataset", authority_level: "official" }), + getIcon({ model: "dataset", moderated_status: "verified" }), ).toEqual({ name: "model_with_badge", color: "saturated-yellow" }); }); }); diff --git a/frontend/src/metabase-types/api/mocks/search.ts b/frontend/src/metabase-types/api/mocks/search.ts index 474dc4e8dc2fea2dc73d8eab2eb1155c6f4d0bbd..c07bde618cb2b04517e71b2de9c2117a79f57fe7 100644 --- a/frontend/src/metabase-types/api/mocks/search.ts +++ b/frontend/src/metabase-types/api/mocks/search.ts @@ -18,6 +18,7 @@ export const createMockSearchResult = ( name: "Mock search result", description: "Mock search result description", model: "card", + display: null, model_index_id: null, model_id: null, archived: null, diff --git a/frontend/src/metabase-types/api/search.ts b/frontend/src/metabase-types/api/search.ts index 9a141343803bdbbde310774f65887490ad255b91..ff621406609b0d6f54dcd68ba20e0a9bc96a45bc 100644 --- a/frontend/src/metabase-types/api/search.ts +++ b/frontend/src/metabase-types/api/search.ts @@ -1,6 +1,6 @@ import type { UserId } from "metabase-types/api/user"; -import type { CardId } from "./card"; +import type { CardDisplayType, CardId } from "./card"; import type { Collection, CollectionId } from "./collection"; import type { DashboardId } from "./dashboard"; import type { DatabaseId, InitialSyncStatus } from "./database"; @@ -96,6 +96,7 @@ export interface SearchResult< bookmark: boolean | null; database_id: DatabaseId; database_name: string | null; + display: CardDisplayType | null; pk_ref: FieldReference | null; table_schema: string | null; collection_authority_level: "official" | null; diff --git a/frontend/src/metabase/common/components/CollectionPicker/components/CollectionPickerModal.tsx b/frontend/src/metabase/common/components/CollectionPicker/components/CollectionPickerModal.tsx index 8df6434b4748bf899d67a36d39e2d22a34a351d3..4e6d5dc0233ae3654132dbee2b758f0ce91b815d 100644 --- a/frontend/src/metabase/common/components/CollectionPicker/components/CollectionPickerModal.tsx +++ b/frontend/src/metabase/common/components/CollectionPicker/components/CollectionPickerModal.tsx @@ -3,7 +3,7 @@ import { t } from "ttag"; import { useToggle } from "metabase/hooks/use-toggle"; import { Button, Icon } from "metabase/ui"; -import type { SearchModel } from "metabase-types/api"; +import type { SearchModel, SearchResult } from "metabase-types/api"; import type { EntityTab } from "../../EntityPicker"; import { EntityPickerModal, defaultOptions } from "../../EntityPicker"; @@ -31,9 +31,7 @@ const canSelectItem = ( return !!item && item.can_write !== false && item.model === "collection"; }; -const searchFilter = ( - searchResults: CollectionPickerItem[], -): CollectionPickerItem[] => { +const searchFilter = (searchResults: SearchResult[]): SearchResult[] => { return searchResults.filter(result => result.can_write); }; diff --git a/frontend/src/metabase/common/components/EntityPicker/components/EntityPickerModal/EntityPickerModal.tsx b/frontend/src/metabase/common/components/EntityPicker/components/EntityPickerModal/EntityPickerModal.tsx index 7596bfaa6423131204b68b3e4bc80868c70a70f1..a2d07c5599ec20d6152b74b30d194f9ce5867a33 100644 --- a/frontend/src/metabase/common/components/EntityPicker/components/EntityPickerModal/EntityPickerModal.tsx +++ b/frontend/src/metabase/common/components/EntityPicker/components/EntityPickerModal/EntityPickerModal.tsx @@ -5,7 +5,11 @@ import { t } from "ttag"; import ErrorBoundary from "metabase/ErrorBoundary"; import { useModalOpen } from "metabase/hooks/use-modal-open"; import { Modal } from "metabase/ui"; -import type { SearchModel, SearchResultId } from "metabase-types/api"; +import type { + SearchModel, + SearchResult, + SearchResultId, +} from "metabase-types/api"; import type { EntityPickerOptions, @@ -47,7 +51,7 @@ export interface EntityPickerModalProps<Model extends string, Item> { onClose: () => void; tabs: EntityTab<Model>[]; options?: Partial<EntityPickerOptions>; - searchResultFilter?: (results: Item[]) => Item[]; + searchResultFilter?: (results: SearchResult[]) => SearchResult[]; actionButtons?: JSX.Element[]; trapFocus?: boolean; } @@ -71,7 +75,9 @@ export function EntityPickerModal< trapFocus = true, }: EntityPickerModalProps<Model, Item>) { const [searchQuery, setSearchQuery] = useState<string>(""); - const [searchResults, setSearchResults] = useState<Item[] | null>(null); + const [searchResults, setSearchResults] = useState<SearchResult[] | null>( + null, + ); const hydratedOptions = useMemo( () => ({ @@ -114,7 +120,7 @@ export function EntityPickerModal< <GrowFlex justify="space-between"> <Modal.Title lh="2.5rem">{title}</Modal.Title> {hydratedOptions.showSearch && ( - <EntityPickerSearchInput<Id, Model, Item> + <EntityPickerSearchInput models={tabModels} setSearchResults={setSearchResults} searchQuery={searchQuery} diff --git a/frontend/src/metabase/common/components/EntityPicker/components/EntityPickerModal/EntityPickerModal.unit.spec.tsx b/frontend/src/metabase/common/components/EntityPicker/components/EntityPickerModal/EntityPickerModal.unit.spec.tsx index c2db194980766a39902736694b43d1769b7ad8fc..deb7101cf4b2adfab2d5f0073a7c5cf00cecd938 100644 --- a/frontend/src/metabase/common/components/EntityPicker/components/EntityPickerModal/EntityPickerModal.unit.spec.tsx +++ b/frontend/src/metabase/common/components/EntityPicker/components/EntityPickerModal/EntityPickerModal.unit.spec.tsx @@ -167,7 +167,7 @@ describe("EntityPickerModal", () => { await screen.findByRole("tab", { name: /2 results for "My"/ }), ).toBeInTheDocument(); - expect(await screen.findAllByTestId("search-result-item")).toHaveLength(2); + expect(await screen.findAllByTestId("result-item")).toHaveLength(2); await userEvent.click(await screen.findByText("Search Result 1")); diff --git a/frontend/src/metabase/common/components/EntityPicker/components/EntityPickerModal/TabsView.tsx b/frontend/src/metabase/common/components/EntityPicker/components/EntityPickerModal/TabsView.tsx index 3bc31980227aa33d545682599b08b2869107d919..6e88f187bd5f61d7677574bbee6ab27ef2539e2f 100644 --- a/frontend/src/metabase/common/components/EntityPicker/components/EntityPickerModal/TabsView.tsx +++ b/frontend/src/metabase/common/components/EntityPicker/components/EntityPickerModal/TabsView.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from "react"; import { useMount, usePrevious } from "react-use"; import { Icon, Tabs } from "metabase/ui"; +import type { SearchResult, SearchResultId } from "metabase-types/api"; import type { EntityTab, TypeWithModel } from "../../types"; import { @@ -10,7 +11,7 @@ import { } from "../EntityPickerSearch"; export const TabsView = < - Id, + Id extends SearchResultId, Model extends string, Item extends TypeWithModel<Id, Model>, >({ @@ -24,7 +25,7 @@ export const TabsView = < tabs: EntityTab<Model>[]; onItemSelect: (item: Item) => void; searchQuery: string; - searchResults: Item[] | null; + searchResults: SearchResult[] | null; selectedItem: Item | null; initialValue?: Partial<Item>; }) => { diff --git a/frontend/src/metabase/common/components/EntityPicker/components/EntityPickerSearch/EntityPickerSearch.styled.tsx b/frontend/src/metabase/common/components/EntityPicker/components/EntityPickerSearch/EntityPickerSearch.styled.tsx deleted file mode 100644 index e1583a0f80227655143ea3b26659728f66cc1fa5..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/common/components/EntityPicker/components/EntityPickerSearch/EntityPickerSearch.styled.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import styled from "@emotion/styled"; - -import { color } from "metabase/lib/colors"; -import { SearchResult } from "metabase/search/components/SearchResult"; - -export const EntityPickerSearchResult = styled(SearchResult)<{ - isSelected: boolean; -}>` - width: 40rem; - border: 1px solid - ${({ isSelected }) => (isSelected ? color("brand") : "transparent")}; - margin-bottom: 1px; -`; diff --git a/frontend/src/metabase/common/components/EntityPicker/components/EntityPickerSearch/EntityPickerSearch.tsx b/frontend/src/metabase/common/components/EntityPicker/components/EntityPickerSearch/EntityPickerSearch.tsx index 7af93984611f19ccf334e1d4f40c067c2b3773d6..ef252f41f1f08829cf5e73a1baba17c7f5862f88 100644 --- a/frontend/src/metabase/common/components/EntityPicker/components/EntityPickerSearch/EntityPickerSearch.tsx +++ b/frontend/src/metabase/common/components/EntityPicker/components/EntityPickerSearch/EntityPickerSearch.tsx @@ -6,16 +6,17 @@ import { useSearchQuery } from "metabase/api"; import EmptyState from "metabase/components/EmptyState"; import { VirtualizedList } from "metabase/components/VirtualizedList"; import { NoObjectError } from "metabase/components/errors/NoObjectError"; -import Search from "metabase/entities/search"; -import { useDispatch } from "metabase/lib/redux"; -import { SearchLoadingSpinner } from "metabase/nav/components/search/SearchResults"; -import type { WrappedResult } from "metabase/search/types"; import { Box, Flex, Icon, Stack, Tabs, TextInput } from "metabase/ui"; -import type { SearchModel, SearchResultId } from "metabase-types/api"; +import type { + SearchModel, + SearchResult, + SearchResultId, +} from "metabase-types/api"; import type { TypeWithModel } from "../../types"; +import { DelayedLoadingSpinner } from "../LoadingSpinner"; +import { ResultItem, ChunkyList } from "../ResultItem"; -import { EntityPickerSearchResult } from "./EntityPickerSearch.styled"; import { getSearchTabText } from "./utils"; const defaultSearchFilter = < @@ -26,11 +27,7 @@ const defaultSearchFilter = < results: Item[], ) => results; -export function EntityPickerSearchInput< - Id extends SearchResultId, - Model extends SearchModel, - Item extends TypeWithModel<Id, Model>, ->({ +export function EntityPickerSearchInput({ searchQuery, setSearchQuery, setSearchResults, @@ -39,9 +36,9 @@ export function EntityPickerSearchInput< }: { searchQuery: string; setSearchQuery: (query: string) => void; - setSearchResults: (results: Item[] | null) => void; + setSearchResults: (results: SearchResult[] | null) => void; models: SearchModel[]; - searchFilter?: (results: Item[]) => Item[]; + searchFilter?: (results: SearchResult[]) => SearchResult[]; }) { const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(searchQuery); useDebounce(() => setDebouncedSearchQuery(searchQuery), 200, [searchQuery]); @@ -58,7 +55,7 @@ export function EntityPickerSearchInput< useLayoutEffect(() => { if (data && !isFetching) { - setSearchResults(searchFilter(data.data as unknown as Item[])); + setSearchResults(searchFilter(data.data)); } else { setSearchResults(null); } @@ -78,7 +75,7 @@ export function EntityPickerSearchInput< } export const EntityPickerSearchResults = < - Id, + Id extends SearchResultId, Model extends string, Item extends TypeWithModel<Id, Model>, >({ @@ -86,38 +83,37 @@ export const EntityPickerSearchResults = < onItemSelect, selectedItem, }: { - searchResults: Item[] | null; + searchResults: SearchResult[] | null; onItemSelect: (item: Item) => void; selectedItem: Item | null; }) => { - const dispatch = useDispatch(); - if (!searchResults) { - return <SearchLoadingSpinner />; + return <DelayedLoadingSpinner text={t`Loading…`} />; } return ( - <Box h="100%"> + <Box h="100%" bg="bg-light"> {searchResults.length > 0 ? ( <Stack h="100%"> <VirtualizedList Wrapper={({ children, ...props }) => ( - <Box p="lg" {...props}> - {children} + <Box p="xl" {...props}> + <ChunkyList>{children}</ChunkyList> </Box> )} > - {searchResults?.map(item => ( - <EntityPickerSearchResult + {searchResults?.map((item, index) => ( + <ResultItem key={item.model + item.id} - result={Search.wrapEntity(item, dispatch)} - onClick={(item: WrappedResult) => { + item={item} + onClick={() => { onItemSelect(item as unknown as Item); }} isSelected={ selectedItem?.id === item.id && selectedItem?.model === item.model } + isLast={index === searchResults.length - 1} /> ))} </VirtualizedList> diff --git a/frontend/src/metabase/common/components/EntityPicker/components/LoadingSpinner/LoadingSpinner.tsx b/frontend/src/metabase/common/components/EntityPicker/components/LoadingSpinner/LoadingSpinner.tsx index d478a2f5ce5a02b0d0b6da5a677373858885e1d3..a6c02c855f256daa590bb5c6dbfc13c66460dd1b 100644 --- a/frontend/src/metabase/common/components/EntityPicker/components/LoadingSpinner/LoadingSpinner.tsx +++ b/frontend/src/metabase/common/components/EntityPicker/components/LoadingSpinner/LoadingSpinner.tsx @@ -4,7 +4,13 @@ import { useMount } from "react-use"; import { Loader, Flex, Text } from "metabase/ui"; export const LoadingSpinner = ({ text }: { text?: string }) => ( - <Flex align="center" justify="center" h="100%" data-testid="loading-spinner"> + <Flex + align="center" + justify="center" + h="100%" + data-testid="loading-spinner" + gap="md" + > <Loader size="lg" /> {!!text && <Text color="text-medium">{text}</Text>} </Flex> diff --git a/frontend/src/metabase/common/components/EntityPicker/components/ResultItem/ResultItem.styled.tsx b/frontend/src/metabase/common/components/EntityPicker/components/ResultItem/ResultItem.styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..22787114e9f7ddff4e0cc58b10eb061844806dc1 --- /dev/null +++ b/frontend/src/metabase/common/components/EntityPicker/components/ResultItem/ResultItem.styled.tsx @@ -0,0 +1,42 @@ +import styled from "@emotion/styled"; + +import { color } from "metabase/lib/colors"; + +export const ChunkyListItem = styled.button<{ + isSelected?: boolean; + isLast?: boolean; +}>` + padding: 1.5rem; + cursor: pointer; + + background-color: ${({ isSelected }) => + isSelected ? color("brand") : "white"}; + + color: ${({ isSelected }) => + isSelected ? color("white") : color("text-dark")}; + + &:hover { + ${({ isSelected }) => + !isSelected + ? `background-color: ${color("brand-lighter")}; + color: ${color("text-dark")};` + : ""} + } + + ${({ isLast }) => + !isLast ? `border-bottom: 1px solid ${color("border")}` : ""}; + + display: flex; + gap: 1rem; + justify-content: space-between; + align-items: center; + width: 100%; +`; + +export const ChunkyList = styled.div` + border: 1px solid ${color("border")}; + border-radius: 0.5rem; + display: flex; + flex-direction: column; + overflow: hidden; +`; diff --git a/frontend/src/metabase/common/components/EntityPicker/components/ResultItem/ResultItem.tsx b/frontend/src/metabase/common/components/EntityPicker/components/ResultItem/ResultItem.tsx new file mode 100644 index 0000000000000000000000000000000000000000..388407015c24503e21c0eaead499987e24d11e8c --- /dev/null +++ b/frontend/src/metabase/common/components/EntityPicker/components/ResultItem/ResultItem.tsx @@ -0,0 +1,74 @@ +import { t } from "ttag"; + +import { Ellipsified } from "metabase/core/components/Ellipsified"; +import { color } from "metabase/lib/colors"; +import { getIcon } from "metabase/lib/icon"; +import { Icon, Flex } from "metabase/ui"; +import type { SearchResult } from "metabase-types/api"; + +import { ChunkyListItem } from "./ResultItem.styled"; + +export type ResultItemType = Pick< + SearchResult, + | "model" + | "collection" + | "name" + | "description" + | "collection_authority_level" + | "moderated_status" + | "display" +>; + +export const ResultItem = ({ + item, + onClick, + isSelected, + isLast, +}: { + item: ResultItemType; + onClick: () => void; + isSelected?: boolean; + isLast?: boolean; +}) => { + const icon = getIcon(item); + + return ( + <ChunkyListItem + onClick={onClick} + isSelected={isSelected} + isLast={isLast} + data-testid="result-item" + > + <Flex gap="md" miw="10rem" align="center" style={{ flex: 1 }}> + <Icon + color={color(icon.color ?? (isSelected ? "white" : "brand"))} + name={icon.name} + style={{ + flexShrink: 0, + }} + /> + <Ellipsified style={{ fontWeight: "bold" }}>{item.name}</Ellipsified> + </Flex> + + {item.model !== "collection" && ( // we don't hydrate parent info for collections right now + <Flex + style={{ + color: isSelected ? color("white") : color("text-light"), + flexShrink: 0, + }} + align="center" + gap="sm" + w="20rem" + > + <Icon + name={getIcon({ model: "collection", ...item.collection }).name} + style={{ flexShrink: 0 }} + /> + <Ellipsified> + {t`in ${item.collection?.name ?? t`Our Analytics`}`} + </Ellipsified> + </Flex> + )} + </ChunkyListItem> + ); +}; diff --git a/frontend/src/metabase/common/components/EntityPicker/components/ResultItem/ResultItem.unit.spec.tsx b/frontend/src/metabase/common/components/EntityPicker/components/ResultItem/ResultItem.unit.spec.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6c941c74a5a3c48da0b67c648ae0bccaf939faa3 --- /dev/null +++ b/frontend/src/metabase/common/components/EntityPicker/components/ResultItem/ResultItem.unit.spec.tsx @@ -0,0 +1,158 @@ +import { setupEnterprisePlugins } from "__support__/enterprise"; +import { mockSettings } from "__support__/settings"; +import { getIcon, renderWithProviders, screen } from "__support__/ui"; +import register from "metabase/visualizations/register"; +import { + createMockSettings, + createMockTokenFeatures, +} from "metabase-types/api/mocks"; +import { createMockState } from "metabase-types/store/mocks"; + +import { ResultItem, type ResultItemType } from "./ResultItem"; + +function setup({ + item, + isSelected = false, + onClick = jest.fn(), +}: { + item: ResultItemType; + isSelected?: boolean; + onClick?: () => void; +}) { + const tokenFeatures = createMockTokenFeatures({ + content_verification: true, + official_collections: true, + }); + const settings = createMockSettings(); + + const settingValuesWithToken = { + ...settings, + "token-features": tokenFeatures, + }; + + const state = createMockState({ + settings: mockSettings(settingValuesWithToken), + }); + + setupEnterprisePlugins(); + + return renderWithProviders( + <ResultItem item={item} isSelected={isSelected} onClick={onClick} />, + { storeInitialState: state }, + ); +} + +const collectionItem: ResultItemType = { + model: "collection", + name: "Foo Collection", + description: "", + collection: { name: "should not show this collection", id: 0 }, + collection_authority_level: null, + moderated_status: null, + display: null, +}; + +const questionItem: ResultItemType = { + model: "card", + name: "My Bar Chart", + description: "", + collection: { name: "My parent collection", id: 101 }, + collection_authority_level: null, + moderated_status: null, + display: "bar", +}; + +const dashboardItem: ResultItemType = { + model: "dashboard", + name: "My Awesome Dashboard ", + description: "This dashboard contains awesome stuff", + collection: { name: "My parent collection", id: 101 }, + collection_authority_level: null, + moderated_status: null, + display: null, +}; + +const questionInOfficialCollection: ResultItemType = { + model: "card", + name: "My Line Chart", + description: "", + collection: { + name: "My official parent collection", + id: 101, + authority_level: "official", + }, + collection_authority_level: "official", + moderated_status: null, + display: "line", +}; + +const verifiedModelItem: ResultItemType = { + model: "dataset", + name: "My Verified Model", + description: "", + collection: { name: "My parent collection", id: 101 }, + collection_authority_level: null, + moderated_status: "verified", + display: null, +}; + +describe("EntityPicker > ResultItem", () => { + beforeAll(() => { + register(); + }); + + it("should render a collection item", () => { + setup({ + item: collectionItem, + }); + expect(screen.getByText("Foo Collection")).toBeInTheDocument(); + expect(screen.queryByText(/should not show/i)).not.toBeInTheDocument(); + expect(getIcon("folder")).toBeInTheDocument(); + }); + + it("should render a bar chart item", () => { + setup({ + item: questionItem, + }); + expect(screen.getByText("My Bar Chart")).toBeInTheDocument(); + expect(getIcon("bar")).toBeInTheDocument(); + + expect(screen.getByText("in My parent collection")).toBeInTheDocument(); + }); + + it("should render a dashboard item", () => { + setup({ + item: dashboardItem, + }); + expect(screen.getByText("My Awesome Dashboard")).toBeInTheDocument(); + + expect(getIcon("dashboard")).toBeInTheDocument(); + expect(screen.getByText("in My parent collection")).toBeInTheDocument(); + }); + + it("should render a line chart item in an official collection", () => { + setup({ + item: questionInOfficialCollection, + }); + expect(screen.getByText("My Line Chart")).toBeInTheDocument(); + expect(getIcon("line")).toBeInTheDocument(); + + expect( + screen.getByText("in My official parent collection"), + ).toBeInTheDocument(); + expect( + screen.getByText("in My official parent collection"), + ).toBeInTheDocument(); + expect(getIcon("badge")).toBeInTheDocument(); + }); + + it("should render a verified model item", () => { + setup({ + item: verifiedModelItem, + }); + expect(screen.getByText("My Verified Model")).toBeInTheDocument(); + + expect(getIcon("model_with_badge")).toBeInTheDocument(); + expect(screen.getByText("in My parent collection")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/metabase/common/components/EntityPicker/components/ResultItem/index.ts b/frontend/src/metabase/common/components/EntityPicker/components/ResultItem/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..4f9f05a0b033d6d72fc910ddd50669a75cb2f0bd --- /dev/null +++ b/frontend/src/metabase/common/components/EntityPicker/components/ResultItem/index.ts @@ -0,0 +1,2 @@ +export * from "./ResultItem"; +export * from "./ResultItem.styled"; diff --git a/frontend/src/metabase/lib/icon.ts b/frontend/src/metabase/lib/icon.ts index d664c6f107fd787aee31cf7ef4f67bdb5484d98c..42932d8db159a2f30e7210e3354c9dcf8893261c 100644 --- a/frontend/src/metabase/lib/icon.ts +++ b/frontend/src/metabase/lib/icon.ts @@ -9,8 +9,10 @@ import type { export type ObjectWithModel = { model: SearchModel; - authority_level?: string; - display?: CardDisplayType; + authority_level?: "official" | string | null; + collection_authority_level?: "official" | string | null; + moderated_status?: "verified" | string | null; + display?: CardDisplayType | null; type?: Collection["type"]; }; @@ -57,4 +59,9 @@ export const getIconBase = ( return { name: modelIconMap?.[item.model] ?? "unknown" }; }; -export const getIcon = PLUGIN_COLLECTIONS.getIcon ?? getIconBase; +export const getIcon = (item: ObjectWithModel, options: IconOptions = {}) => { + if (PLUGIN_COLLECTIONS) { + return PLUGIN_COLLECTIONS.getIcon(item, options); + } + return getIconBase(item, options); +}; diff --git a/frontend/src/metabase/plugins/index.ts b/frontend/src/metabase/plugins/index.ts index c1a4beaa6b9b8c42447d078030ec6ce038bcf13c..9a1241aaf1e914fe2b9b795966e37b3b59893c41 100644 --- a/frontend/src/metabase/plugins/index.ts +++ b/frontend/src/metabase/plugins/index.ts @@ -16,7 +16,7 @@ import type { AvailableModelFilters, ModelFilterControlsProps, } from "metabase/browse/utils"; -import type { IconData, ObjectWithModel } from "metabase/lib/icon"; +import { getIconBase } from "metabase/lib/icon"; import PluginPlaceholder from "metabase/plugins/components/PluginPlaceholder"; import type { SearchFilterComponent } from "metabase/search/types"; import type { IconName, IconProps } from "metabase/ui"; @@ -235,8 +235,6 @@ type AuthorityLevelMenuItem = { action: () => void; }; -type GetIconType = ((item: ObjectWithModel) => IconData) | null; - export const PLUGIN_COLLECTIONS = { AUTHORITY_LEVEL: { [JSON.stringify(AUTHORITY_LEVEL_REGULAR.type)]: AUTHORITY_LEVEL_REGULAR, @@ -259,7 +257,7 @@ export const PLUGIN_COLLECTIONS = { _collection: Collection, _onUpdate: (collection: Collection, values: Partial<Collection>) => void, ): AuthorityLevelMenuItem[] => [], - getIcon: null as GetIconType, + getIcon: getIconBase, }; export type CollectionAuthorityLevelIcon = ComponentType<