From 5a087ba8ca271892ba5a195e7e000c44393bc0c6 Mon Sep 17 00:00:00 2001 From: Shrabanti Paul Date: Thu, 14 May 2026 17:17:13 +0530 Subject: [PATCH 1/2] add nlp search in marketplace --- .../MarketplaceSearchBar.component.tsx | 173 +++++-- .../MarketplaceSearchBar.test.tsx | 482 ++++++++++++++++++ .../marketplace-search-bar.less | 28 + .../resources/ui/src/enums/search.enum.ts | 1 + .../ui/src/interface/search.interface.ts | 7 + 5 files changed, 636 insertions(+), 55 deletions(-) create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/DataMarketplace/MarketplaceSearchBar/MarketplaceSearchBar.test.tsx diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataMarketplace/MarketplaceSearchBar/MarketplaceSearchBar.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataMarketplace/MarketplaceSearchBar/MarketplaceSearchBar.component.tsx index 6bf447834a80..e43211b31d35 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataMarketplace/MarketplaceSearchBar/MarketplaceSearchBar.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataMarketplace/MarketplaceSearchBar/MarketplaceSearchBar.component.tsx @@ -21,13 +21,19 @@ import { debounce } from 'lodash'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; +import { ReactComponent as IconSuggestionsActive } from '../../../assets/svg/ic-suggestions-active.svg'; +import { ReactComponent as IconSuggestionsBlue } from '../../../assets/svg/ic-suggestions-blue.svg'; import { INITIAL_PAGING_VALUE } from '../../../constants/constants'; import { SearchIndex } from '../../../enums/search.enum'; import { DataProduct } from '../../../generated/entity/domains/dataProduct'; import { Domain } from '../../../generated/entity/domains/domain'; import { useMarketplaceRecentSearches } from '../../../hooks/useMarketplaceRecentSearches'; import { useMarketplaceStore } from '../../../hooks/useMarketplaceStore'; -import { searchQuery } from '../../../rest/searchAPI'; +import { + getNLPEnabledStatus, + nlqSearch, + searchQuery, +} from '../../../rest/searchAPI'; import { getDataProductIconByUrl } from '../../../utils/DataProductUtils'; import { getDomainIcon } from '../../../utils/DomainUtils'; import { getDomainDetailsPath } from '../../../utils/RouterUtils'; @@ -40,6 +46,8 @@ const MarketplaceSearchBar = ({ isEditView }: { isEditView?: boolean }) => { const { t } = useTranslation(); const navigate = useNavigate(); const { dataProductBasePath } = useMarketplaceStore(); + const [isNLPEnabled, setIsNLPEnabled] = useState(true); + const [isNLQActive, setIsNLQActive] = useState(false); const [searchValue, setSearchValue] = useState(''); const [isOpen, setIsOpen] = useState(false); const [dataProducts, setDataProducts] = useState([]); @@ -48,41 +56,73 @@ const MarketplaceSearchBar = ({ isEditView }: { isEditView?: boolean }) => { const containerRef = useRef(null); const { addSearch } = useMarketplaceRecentSearches(); - const fetchResults = useCallback(async (query: string) => { - if (!query.trim()) { - setDataProducts([]); - setDomains([]); + useEffect(() => { + getNLPEnabledStatus() + .then(setIsNLPEnabled) + .catch(() => setIsNLPEnabled(false)); + }, []); - return; - } - setIsSearching(true); - try { - const [dpRes, domainRes] = await Promise.all([ - searchQuery({ - query, - pageNumber: INITIAL_PAGING_VALUE, - pageSize: PAGE_SIZE, - searchIndex: SearchIndex.DATA_PRODUCT, - }), - searchQuery({ - query, - pageNumber: INITIAL_PAGING_VALUE, - pageSize: PAGE_SIZE, - searchIndex: SearchIndex.DOMAIN, - }), - ]); + const fetchResults = useCallback( + async (query: string) => { + if (!query.trim()) { + setDataProducts([]); + setDomains([]); - setDataProducts( - dpRes.hits.hits.map((hit) => hit._source) as DataProduct[] - ); - setDomains(domainRes.hits.hits.map((hit) => hit._source) as Domain[]); - } catch { - setDataProducts([]); - setDomains([]); - } finally { - setIsSearching(false); - } - }, []); + return; + } + setIsSearching(true); + try { + if (isNLPEnabled && isNLQActive) { + const res = await nlqSearch({ + query, + pageNumber: INITIAL_PAGING_VALUE, + pageSize: PAGE_SIZE * 2, + searchIndex: SearchIndex.MARKETPLACE, + }); + + const hits = res.hits.hits; + setDataProducts( + hits + .filter((h) => h._source.entityType === SearchIndex.DATA_PRODUCT) + .slice(0, PAGE_SIZE) + .map((h) => h._source as unknown as DataProduct) + ); + setDomains( + hits + .filter((h) => h._source.entityType === SearchIndex.DOMAIN) + .slice(0, PAGE_SIZE) + .map((h) => h._source as unknown as Domain) + ); + } else { + const [dpRes, domainRes] = await Promise.all([ + searchQuery({ + query, + pageNumber: INITIAL_PAGING_VALUE, + pageSize: PAGE_SIZE, + searchIndex: SearchIndex.DATA_PRODUCT, + }), + searchQuery({ + query, + pageNumber: INITIAL_PAGING_VALUE, + pageSize: PAGE_SIZE, + searchIndex: SearchIndex.DOMAIN, + }), + ]); + + setDataProducts( + dpRes.hits.hits.map((hit) => hit._source) as DataProduct[] + ); + setDomains(domainRes.hits.hits.map((hit) => hit._source) as Domain[]); + } + } catch { + setDataProducts([]); + setDomains([]); + } finally { + setIsSearching(false); + } + }, + [isNLPEnabled, isNLQActive] + ); const debouncedFetch = useMemo( () => debounce(fetchResults, 400), @@ -248,27 +288,50 @@ const MarketplaceSearchBar = ({ isEditView }: { isEditView?: boolean }) => { className="marketplace-search-bar" data-testid="marketplace-search-bar" ref={containerRef}> - handleChange(value)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - handleSearch(searchValue); - } - }} - /> +
+
+ {isNLPEnabled ? ( + + ) : ( + + )} +
+ handleChange(value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleSearch(searchValue); + } + }} + /> +
({ + ...jest.requireActual('react-router-dom'), + useNavigate: () => mockNavigate, +})); + +jest.mock('../../../rest/searchAPI', () => ({ + getNLPEnabledStatus: jest.fn().mockResolvedValue(true), + nlqSearch: jest.fn().mockResolvedValue({ hits: { hits: [] } }), + searchQuery: jest.fn().mockResolvedValue({ hits: { hits: [] } }), +})); + +jest.mock('../../../hooks/useMarketplaceStore', () => ({ + useMarketplaceStore: jest.fn().mockReturnValue({ + dataProductBasePath: '/dataProduct', + }), +})); + +jest.mock('../../../hooks/useMarketplaceRecentSearches', () => ({ + useMarketplaceRecentSearches: jest.fn().mockReturnValue({ + addSearch: jest.fn(), + }), +})); + +jest.mock('../../../utils/DataProductUtils', () => ({ + getDataProductIconByUrl: jest.fn().mockReturnValue(dp-icon), +})); + +jest.mock('../../../utils/DomainUtils', () => ({ + getDomainIcon: jest.fn().mockReturnValue(domain-icon), +})); + +jest.mock('../../../utils/RouterUtils', () => ({ + getDomainDetailsPath: jest.fn((fqn) => `/domain/${fqn}`), +})); + +jest.mock('../../../utils/StringsUtils', () => ({ + getEncodedFqn: jest.fn((fqn) => fqn), +})); + +jest.mock('@openmetadata/ui-core-components', () => { + const Input = ({ + value, + onChange, + onKeyDown, + placeholder, + isDisabled, + ...rest + }: { + value?: string; + onChange?: (val: string) => void; + onKeyDown?: (e: React.KeyboardEvent) => void; + placeholder?: string; + isDisabled?: boolean; + } & Record) => ( + onChange?.(e.target.value)} + onKeyDown={onKeyDown} + {...rest} + /> + ); + + const SelectPopover = ({ + children, + isOpen, + }: { + children: React.ReactNode; + isOpen?: boolean; + }) => (isOpen ?
{children}
: null); + + const Typography = ({ + children, + ...rest + }: { + children?: React.ReactNode; + } & Record) => {children}; + + return { Input, SelectPopover, Typography }; +}); + +jest.mock('@untitledui/icons', () => ({ + SearchLg: () => search, +})); + +const mockDataProducts = [ + { + id: 'dp-1', + name: 'product-one', + displayName: 'Product One', + fullyQualifiedName: 'domain.product-one', + style: { iconURL: '' }, + }, +]; + +const mockDomains = [ + { + id: 'domain-1', + name: 'marketing', + displayName: 'Marketing', + fullyQualifiedName: 'marketing', + style: { iconURL: '' }, + }, +]; + +const renderComponent = (props = {}) => + render(, { wrapper: MemoryRouter }); + +describe('MarketplaceSearchBar', () => { + beforeEach(() => { + jest.clearAllMocks(); + (getNLPEnabledStatus as jest.Mock).mockResolvedValue(true); + (searchQuery as jest.Mock).mockResolvedValue({ hits: { hits: [] } }); + (nlqSearch as jest.Mock).mockResolvedValue({ hits: { hits: [] } }); + }); + + it('renders the search input', async () => { + await act(async () => { + renderComponent(); + }); + + expect(screen.getByTestId('marketplace-search-bar')).toBeInTheDocument(); + expect(screen.getByTestId('marketplace-search-input')).toBeInTheDocument(); + }); + + it('shows NLQ toggle button when NLP is enabled', async () => { + await act(async () => { + renderComponent(); + }); + + const toggleBtn = screen.getByTestId('marketplace-nlq-toggle'); + + expect(toggleBtn).toBeInTheDocument(); + expect(toggleBtn).not.toHaveClass('active'); + }); + + it('shows search icon fallback when NLP is disabled', async () => { + (getNLPEnabledStatus as jest.Mock).mockResolvedValue(false); + + await act(async () => { + renderComponent(); + }); + + await waitFor(() => { + expect(screen.getByTestId('search-icon')).toBeInTheDocument(); + expect( + screen.queryByTestId('marketplace-nlq-toggle') + ).not.toBeInTheDocument(); + }); + }); + + it('toggles NLQ active state when the toggle button is clicked', async () => { + await act(async () => { + renderComponent(); + }); + + const toggleBtn = screen.getByTestId('marketplace-nlq-toggle'); + + expect(toggleBtn).not.toHaveClass('active'); + expect(toggleBtn).toHaveAttribute( + 'title', + 'label.use-natural-language-search' + ); + + await act(async () => { + fireEvent.click(toggleBtn); + }); + + expect(toggleBtn).toHaveClass('active'); + expect(toggleBtn).toHaveAttribute( + 'title', + 'message.natural-language-search-active' + ); + + await act(async () => { + fireEvent.click(toggleBtn); + }); + + expect(toggleBtn).not.toHaveClass('active'); + }); + + it('calls searchQuery for data products and domains on input change', async () => { + (searchQuery as jest.Mock).mockResolvedValue({ + hits: { hits: mockDataProducts.map((dp) => ({ _source: dp })) }, + }); + + await act(async () => { + renderComponent(); + }); + + const input = screen.getByTestId('marketplace-search-input'); + + await act(async () => { + fireEvent.change(input, { target: { value: 'product' } }); + }); + + await waitFor(() => { + expect(searchQuery).toHaveBeenCalledWith( + expect.objectContaining({ query: 'product' }) + ); + }); + }); + + it('calls nlqSearch when NLQ is active and NLP is enabled', async () => { + (nlqSearch as jest.Mock).mockResolvedValue({ hits: { hits: [] } }); + + await act(async () => { + renderComponent(); + }); + + await act(async () => { + fireEvent.click(screen.getByTestId('marketplace-nlq-toggle')); + }); + + const input = screen.getByTestId('marketplace-search-input'); + + await act(async () => { + fireEvent.change(input, { target: { value: 'revenue data' } }); + }); + + await waitFor(() => { + expect(nlqSearch).toHaveBeenCalledWith( + expect.objectContaining({ query: 'revenue data' }) + ); + }); + }); + + it('does not open popover when input is empty', async () => { + await act(async () => { + renderComponent(); + }); + + const input = screen.getByTestId('marketplace-search-input'); + + await act(async () => { + fireEvent.change(input, { target: { value: '' } }); + }); + + expect(screen.queryByTestId('search-popover')).not.toBeInTheDocument(); + }); + + it('shows data product results in the popover', async () => { + (searchQuery as jest.Mock) + .mockResolvedValueOnce({ + hits: { hits: mockDataProducts.map((dp) => ({ _source: dp })) }, + }) + .mockResolvedValueOnce({ hits: { hits: [] } }); + + await act(async () => { + renderComponent(); + }); + + const input = screen.getByTestId('marketplace-search-input'); + + await act(async () => { + fireEvent.change(input, { target: { value: 'product' } }); + }); + + await waitFor(() => { + expect(screen.getByTestId('search-result-dp-dp-1')).toBeInTheDocument(); + expect(screen.getByText('Product One')).toBeInTheDocument(); + }); + }); + + it('shows domain results in the popover', async () => { + (searchQuery as jest.Mock) + .mockResolvedValueOnce({ hits: { hits: [] } }) + .mockResolvedValueOnce({ + hits: { hits: mockDomains.map((d) => ({ _source: d })) }, + }); + + await act(async () => { + renderComponent(); + }); + + const input = screen.getByTestId('marketplace-search-input'); + + await act(async () => { + fireEvent.change(input, { target: { value: 'marketing' } }); + }); + + await waitFor(() => { + expect( + screen.getByTestId('search-result-domain-domain-1') + ).toBeInTheDocument(); + expect(screen.getByText('Marketing')).toBeInTheDocument(); + }); + }); + + it('shows no-data message when search returns empty results', async () => { + (searchQuery as jest.Mock).mockResolvedValue({ hits: { hits: [] } }); + + await act(async () => { + renderComponent(); + }); + + const input = screen.getByTestId('marketplace-search-input'); + + await act(async () => { + fireEvent.change(input, { target: { value: 'xyz-no-match' } }); + }); + + await waitFor(() => { + expect(screen.getByTestId('search-popover')).toBeInTheDocument(); + expect(screen.getByText(/no-data-found/i)).toBeInTheDocument(); + }); + }); + + it('navigates to data product page when a result is clicked', async () => { + (searchQuery as jest.Mock) + .mockResolvedValueOnce({ + hits: { hits: mockDataProducts.map((dp) => ({ _source: dp })) }, + }) + .mockResolvedValueOnce({ hits: { hits: [] } }); + + await act(async () => { + renderComponent(); + }); + + const input = screen.getByTestId('marketplace-search-input'); + + await act(async () => { + fireEvent.change(input, { target: { value: 'product' } }); + }); + + await waitFor(() => { + expect(screen.getByTestId('search-result-dp-dp-1')).toBeInTheDocument(); + }); + + await act(async () => { + fireEvent.click(screen.getByTestId('search-result-dp-dp-1')); + }); + + expect(mockNavigate).toHaveBeenCalledWith( + '/dataProduct/domain.product-one', + { state: { fromMarketplace: true } } + ); + }); + + it('navigates to domain page when a domain result is clicked', async () => { + (searchQuery as jest.Mock) + .mockResolvedValueOnce({ hits: { hits: [] } }) + .mockResolvedValueOnce({ + hits: { hits: mockDomains.map((d) => ({ _source: d })) }, + }); + + await act(async () => { + renderComponent(); + }); + + const input = screen.getByTestId('marketplace-search-input'); + + await act(async () => { + fireEvent.change(input, { target: { value: 'marketing' } }); + }); + + await waitFor(() => { + expect( + screen.getByTestId('search-result-domain-domain-1') + ).toBeInTheDocument(); + }); + + await act(async () => { + fireEvent.click(screen.getByTestId('search-result-domain-domain-1')); + }); + + expect(mockNavigate).toHaveBeenCalledWith('/domain/marketing', { + state: { fromMarketplace: true }, + }); + }); + + it('clears results and closes popover when input is cleared', async () => { + (searchQuery as jest.Mock) + .mockResolvedValueOnce({ + hits: { hits: mockDataProducts.map((dp) => ({ _source: dp })) }, + }) + .mockResolvedValueOnce({ hits: { hits: [] } }); + + await act(async () => { + renderComponent(); + }); + + const input = screen.getByTestId('marketplace-search-input'); + + await act(async () => { + fireEvent.change(input, { target: { value: 'product' } }); + }); + + await waitFor(() => { + expect(screen.getByTestId('search-popover')).toBeInTheDocument(); + }); + + await act(async () => { + fireEvent.change(input, { target: { value: '' } }); + }); + + expect(screen.queryByTestId('search-popover')).not.toBeInTheDocument(); + }); + + it('does not search when component is in edit view', async () => { + await act(async () => { + renderComponent({ isEditView: true }); + }); + + const input = screen.getByTestId('marketplace-search-input'); + + expect(input).toBeDisabled(); + }); + + it('triggers search on Enter key press', async () => { + (searchQuery as jest.Mock).mockResolvedValue({ hits: { hits: [] } }); + + await act(async () => { + renderComponent(); + }); + + const input = screen.getByTestId('marketplace-search-input'); + + await act(async () => { + fireEvent.change(input, { target: { value: 'test query' } }); + }); + + await act(async () => { + fireEvent.keyDown(input, { key: 'Enter' }); + }); + + await waitFor(() => { + expect(searchQuery).toHaveBeenCalled(); + }); + }); + + it('handles search API error gracefully', async () => { + (searchQuery as jest.Mock).mockRejectedValue(new Error('API error')); + + await act(async () => { + renderComponent(); + }); + + const input = screen.getByTestId('marketplace-search-input'); + + await act(async () => { + fireEvent.change(input, { target: { value: 'error case' } }); + }); + + await waitFor(() => { + expect(screen.getByTestId('search-popover')).toBeInTheDocument(); + expect(screen.getByText(/no-data-found/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataMarketplace/MarketplaceSearchBar/marketplace-search-bar.less b/openmetadata-ui/src/main/resources/ui/src/components/DataMarketplace/MarketplaceSearchBar/marketplace-search-bar.less index 93daeb702e1c..aa21f1739e16 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataMarketplace/MarketplaceSearchBar/marketplace-search-bar.less +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataMarketplace/MarketplaceSearchBar/marketplace-search-bar.less @@ -15,6 +15,34 @@ .marketplace-search-bar { margin-bottom: 8px; position: relative; + + .marketplace-nlq-button { + display: flex; + align-items: center; + justify-content: center; + border: 0.5px solid @nlp-border-color; + border-radius: @border-rad-xs; + padding: 4px; + background-color: @primary-button-background; + cursor: pointer; + + svg { + width: 14px; + height: 14px; + fill: transparent; + } + + &.active { + padding: 0; + border: none; + + svg { + width: 24px; + height: 24px; + fill: none; + } + } + } } .marketplace-search-results { diff --git a/openmetadata-ui/src/main/resources/ui/src/enums/search.enum.ts b/openmetadata-ui/src/main/resources/ui/src/enums/search.enum.ts index a9925e2658b9..18c6545fd221 100644 --- a/openmetadata-ui/src/main/resources/ui/src/enums/search.enum.ts +++ b/openmetadata-ui/src/main/resources/ui/src/enums/search.enum.ts @@ -57,4 +57,5 @@ export enum SearchIndex { SPREADSHEET = 'spreadsheet', WORKSHEET = 'worksheet', KNOWLEDGE_PAGE_INDEX = 'page', + MARKETPLACE = 'marketplace', } diff --git a/openmetadata-ui/src/main/resources/ui/src/interface/search.interface.ts b/openmetadata-ui/src/main/resources/ui/src/interface/search.interface.ts index 37f5787af0fd..d06ac1d3c6f4 100644 --- a/openmetadata-ui/src/main/resources/ui/src/interface/search.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/interface/search.interface.ts @@ -49,6 +49,7 @@ import { DatabaseService } from '../generated/entity/services/databaseService'; import { DriveService } from '../generated/entity/services/driveService'; import { IngestionPipeline } from '../generated/entity/services/ingestionPipelines/ingestionPipeline'; import { MessagingService } from '../generated/entity/services/messagingService'; +import { MetadataService } from '../generated/entity/services/metadataService'; import { MlmodelService } from '../generated/entity/services/mlmodelService'; import { PipelineService } from '../generated/entity/services/pipelineService'; import { SearchService } from '../generated/entity/services/searchService'; @@ -210,6 +211,10 @@ export interface StorageServiceSearchSource export interface APIServiceSearchSource extends SearchSourceBase, APIService {} +export interface MetadataServiceSearchSource + extends SearchSourceBase, + MetadataService {} + export interface DriveServiceSearchSource extends SearchSourceBase, DriveService {} @@ -308,6 +313,7 @@ export type SearchIndexSearchSourceMapping = { [SearchIndex.TEST_SUITE]: TestSuiteSearchSource; [SearchIndex.INGESTION_PIPELINE]: IngestionPipelineSearchSource; [SearchIndex.API_SERVICE]: APIServiceSearchSource; + [SearchIndex.METADATA_SERVICE]: MetadataServiceSearchSource; [SearchIndex.API_COLLECTION]: APICollectionSearchSource; [SearchIndex.API_ENDPOINT]: APIEndpointSearchSource; [SearchIndex.METRIC]: MetricSearchSource; @@ -317,6 +323,7 @@ export type SearchIndexSearchSourceMapping = { [SearchIndex.WORKSHEET]: WorksheetSearchSource; [SearchIndex.COLUMN]: TableColumnSearchSource; [SearchIndex.KNOWLEDGE_PAGE_INDEX]: KnowledgePageSearchSource; + [SearchIndex.MARKETPLACE]: DataProductSearchSource | DomainSearchSource; }; export type SearchRequest< From 5e0fc5cd4599586c2983359369962e0825ef8fe6 Mon Sep 17 00:00:00 2001 From: shrabantipaul-collate Date: Fri, 15 May 2026 10:13:05 +0530 Subject: [PATCH 2/2] Disable NLP feature in MarketplaceSearchBar --- .../MarketplaceSearchBar/MarketplaceSearchBar.component.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataMarketplace/MarketplaceSearchBar/MarketplaceSearchBar.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataMarketplace/MarketplaceSearchBar/MarketplaceSearchBar.component.tsx index e43211b31d35..92d70011a4f2 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataMarketplace/MarketplaceSearchBar/MarketplaceSearchBar.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataMarketplace/MarketplaceSearchBar/MarketplaceSearchBar.component.tsx @@ -46,7 +46,7 @@ const MarketplaceSearchBar = ({ isEditView }: { isEditView?: boolean }) => { const { t } = useTranslation(); const navigate = useNavigate(); const { dataProductBasePath } = useMarketplaceStore(); - const [isNLPEnabled, setIsNLPEnabled] = useState(true); + const [isNLPEnabled, setIsNLPEnabled] = useState(false); const [isNLQActive, setIsNLQActive] = useState(false); const [searchValue, setSearchValue] = useState(''); const [isOpen, setIsOpen] = useState(false);