/** * Copyright (c) 2020-present, Goldman Sachs * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { type JSX, useState, useMemo, useCallback, useEffect } from 'react'; import { Autocomplete, Box, CircularProgress, FormControlLabel, IconButton, InputAdornment, Menu, MenuItem, Switch, TextField, Typography, type TextFieldProps, } from '@mui/material'; import { clsx, SearchIcon, TuneIcon } from '@finos/legend-art'; import { observer } from 'mobx-react-lite'; import { LegendMarketplaceInfoTooltip } from '../InfoTooltip/LegendMarketplaceInfoTooltip.js'; import { LegendMarketplaceTelemetryHelper } from '../../__lib__/LegendMarketplaceTelemetryHelper.js'; import { MarketplaceSearchMode } from '../../__lib__/LegendMarketplaceSearchMode.js'; import { useLegendMarketplaceBaseStore } from '../../application/providers/LegendMarketplaceFrameworkProvider.js'; import { createDefaultSuggestions, createAutosuggestSuggestions, createSearchQuerySuggestion, createLoadingSuggestion, SearchSuggestionType, SEARCH_SUGGESTION_CONSTANTS, type SearchSuggestion, } from '../../utils/SearchSuggestions.js'; import { debounce, type DebouncedFunc, assertErrorThrown, LogEvent, } from '@finos/legend-shared'; import { APPLICATION_EVENT } from '@finos/legend-application'; import { generatePathForDataProductSearchResult, convertAutosuggestResultToSearchResult, } from '../../utils/SearchUtils.js'; export interface Vendor { provider: string; description: string; type: string; } // Re-exported for existing call sites that import the mode enum from the search bar. export { MarketplaceSearchMode }; interface SearchModeOption { mode: MarketplaceSearchMode; label: string; tooltip: string; } /** * The alternate search modes offered from the search bar's settings menu. Declared * once and rendered via `.map()` so adding/removing a mode doesn't mean copy-pasting * another `MenuItem`/`Switch`/`FormControlLabel` block. */ const SEARCH_MODE_OPTIONS: SearchModeOption[] = [ { mode: MarketplaceSearchMode.PRODUCER, label: 'Producer Search', tooltip: 'Use this search if you have just created a data product and would like to immediately see it', }, { mode: MarketplaceSearchMode.DATA_FIELDS, label: 'Field Search', tooltip: 'Use this search to discover data products and datasets that contain a specific field', }, ]; const SearchModeToggleMenuItem: React.FC<{ option: SearchModeOption; searchMode: MarketplaceSearchMode; onToggle: (mode: MarketplaceSearchMode, isEnabled: boolean) => void; }> = ({ option, searchMode, onToggle }) => ( onToggle(option.mode, event.target.checked)} /> } label={ <> {option.label} > } /> ); export const LegendMarketplaceSearchBar = observer( (props: { onSearch?: (query: string | undefined, mode: MarketplaceSearchMode) => void; stateSearchQuery?: string | undefined; placeholder?: string; onChange?: (query: string) => void; className?: string | undefined; showSettings?: boolean; stateSearchMode?: MarketplaceSearchMode; enableAutosuggest?: boolean; }): JSX.Element => { const { onSearch, stateSearchQuery, placeholder, onChange, className, showSettings, stateSearchMode, enableAutosuggest = true, } = props; const legendMarketplaceBaseStore = useLegendMarketplaceBaseStore(); const applicationStore = legendMarketplaceBaseStore.applicationStore; const [searchQuery, setSearchQuery] = useState( stateSearchQuery ?? '', ); const [searchMode, setSearchMode] = useState( stateSearchMode ?? MarketplaceSearchMode.DATA_SPACES, ); const [searchMenuAnchorEl, setSearchMenuAnchorEl] = useState(); const [suggestions, setSuggestions] = useState([]); const [loadingSuggestions, setLoadingSuggestions] = useState(false); const [isAutosuggestPopupOpen, setIsAutosuggestPopupOpen] = useState(false); const searchMenuOpen = Boolean(searchMenuAnchorEl); const defaultSuggestionsFromConfig = applicationStore.config.options.defaultSearchSuggestions; const fetchAutosuggestions = useCallback( async (query: string, signal?: AbortSignal): Promise => { if (!enableAutosuggest) { return; } try { const client = legendMarketplaceBaseStore.marketplaceServerClient; const fetchSuggestions = searchMode === MarketplaceSearchMode.LAKEHOUSE_ACCESS ? client.getLakehouseAccessAutosuggestions : client.getAutosuggestions; const response = await fetchSuggestions( query, legendMarketplaceBaseStore.envState.lakehouseEnvironment, SEARCH_SUGGESTION_CONSTANTS.AUTOSUGGEST_LIMIT, signal, ); const autosuggestResults = response.results; const userQuerySuggestion = createSearchQuerySuggestion(query); if (autosuggestResults.length > 0) { setSuggestions([ userQuerySuggestion, ...createAutosuggestSuggestions(autosuggestResults), ]); } else { setSuggestions([userQuerySuggestion]); } } catch (error) { assertErrorThrown(error); if (error.name === 'AbortError') { return; } applicationStore.logService.error( LogEvent.create(APPLICATION_EVENT.GENERIC_FAILURE), error, ); const fallbackQuerySuggestion = createSearchQuerySuggestion(query); setSuggestions([fallbackQuerySuggestion]); } finally { if (!signal?.aborted) { setLoadingSuggestions(false); } } }, [ enableAutosuggest, searchMode, legendMarketplaceBaseStore.marketplaceServerClient, legendMarketplaceBaseStore.envState.lakehouseEnvironment, applicationStore.logService, ], ); const debouncedFetchAutosuggestions: DebouncedFunc< typeof fetchAutosuggestions > = useMemo( () => debounce( fetchAutosuggestions, SEARCH_SUGGESTION_CONSTANTS.AUTOSUGGEST_DEBOUNCE_DELAY, ), [fetchAutosuggestions], ); // Cleanup debounced function on unmount useEffect(() => { return () => { debouncedFetchAutosuggestions.cancel(); }; }, [debouncedFetchAutosuggestions]); // Ensure component's state is in sync with external state useEffect(() => { setSearchQuery(stateSearchQuery ?? ''); }, [stateSearchQuery]); useEffect(() => { setSearchMode(stateSearchMode ?? MarketplaceSearchMode.DATA_SPACES); }, [stateSearchMode]); useEffect(() => { const abortController = new AbortController(); if (isAutosuggestPopupOpen) { if (!searchQuery || searchQuery.trim().length === 0) { setSuggestions( createDefaultSuggestions(defaultSuggestionsFromConfig), ); setLoadingSuggestions(false); } else { const userQuerySuggestion = createSearchQuerySuggestion(searchQuery); const loadingIndicator = createLoadingSuggestion(); setSuggestions([userQuerySuggestion, loadingIndicator]); setLoadingSuggestions(true); // eslint-disable-next-line no-void void debouncedFetchAutosuggestions( searchQuery, abortController.signal, ); } } return () => { abortController.abort(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [searchQuery, isAutosuggestPopupOpen, debouncedFetchAutosuggestions]); const handleInputChange = ( _event: React.SyntheticEvent, newInputValue: string, ): void => { setSearchQuery(newInputValue); onChange?.(newInputValue); }; const handleSuggestionSelection = ( _event: React.SyntheticEvent, selectedSuggestion: SearchSuggestion | string | null, ): void => { if (!selectedSuggestion) { return; } if (typeof selectedSuggestion === 'string') { setSearchQuery(selectedSuggestion); return; } if (selectedSuggestion.type === SearchSuggestionType.LOADING) { return; } const selectedQuery = selectedSuggestion.query; setSearchQuery(selectedQuery); if ( selectedSuggestion.type === SearchSuggestionType.SEARCH_QUERY || selectedSuggestion.type === SearchSuggestionType.DEFAULT ) { onSearch?.(selectedQuery, searchMode); LegendMarketplaceTelemetryHelper.logEvent_SearchAutosuggestSelection( applicationStore.telemetryService, selectedQuery, selectedSuggestion.type, ); } else { const autosuggestResult = selectedSuggestion.autosuggestResult; if (autosuggestResult) { const searchResult = convertAutosuggestResultToSearchResult(autosuggestResult); const dataProductViewerPath = generatePathForDataProductSearchResult(searchResult); if (dataProductViewerPath) { applicationStore.navigationService.navigator.visitAddress( applicationStore.navigationService.navigator.generateAddress( dataProductViewerPath, ), ); } LegendMarketplaceTelemetryHelper.logEvent_SearchAutosuggestSelection( applicationStore.telemetryService, selectedQuery, selectedSuggestion.type, ); } } }; const handleSubmit = (event: React.FormEvent): void => { event.preventDefault(); onSearch?.(searchQuery, searchMode); }; const getOptionLabel = (option: SearchSuggestion | string): string => typeof option === 'string' ? option : option.query; const filterOptions = (options: SearchSuggestion[]): SearchSuggestion[] => { return options; }; const getGroupLabel = (option: SearchSuggestion | string): string => { if (typeof option === 'string') { return ''; } if ( option.type === SearchSuggestionType.SEARCH_QUERY || option.type === SearchSuggestionType.LOADING ) { return ''; } return option.type === SearchSuggestionType.DEFAULT ? SEARCH_SUGGESTION_CONSTANTS.GROUP_HEADER_SUGGESTED_SEARCHES : SEARCH_SUGGESTION_CONSTANTS.GROUP_HEADER_DATA_PRODUCTS; }; return ( { if (enableAutosuggest) { setIsAutosuggestPopupOpen(true); } }} onClose={() => { setIsAutosuggestPopupOpen(false); }} value={null} inputValue={searchQuery} onInputChange={handleInputChange} onChange={handleSuggestionSelection} options={suggestions} filterOptions={filterOptions} getOptionLabel={getOptionLabel} groupBy={getGroupLabel} slotProps={{ popper: { className: 'legend-marketplace__search-bar__dropdown', modifiers: [ { name: 'offset', options: { offset: [0, 4], }, }, { name: 'sameWidth', enabled: true, phase: 'beforeWrite', requires: ['computeStyles'], fn: ({ state }) => { if (state.styles.popper) { state.styles.popper.width = `${state.rects.reference.width}px`; } }, effect: ({ state }) => { const referenceWidth = ( state.elements.reference as HTMLElement ).offsetWidth; state.elements.popper.style.width = `${referenceWidth}px`; }, }, ], placement: 'bottom-start', }, }} renderGroup={(params) => ( {params.group === SEARCH_SUGGESTION_CONSTANTS.GROUP_HEADER_DATA_PRODUCTS && ( )} {params.group && ( {params.group} )} {params.children} )} renderOption={(params, suggestionOption) => { if (typeof suggestionOption === 'string') { return ( {suggestionOption} ); } if (suggestionOption.type === SearchSuggestionType.SEARCH_QUERY) { return ( {suggestionOption.query} ); } if (suggestionOption.type === SearchSuggestionType.LOADING) { return ( {suggestionOption.query} ); } if (suggestionOption.type === SearchSuggestionType.DEFAULT) { return ( {suggestionOption.query} ); } const autosuggestResult = suggestionOption.autosuggestResult; if (!autosuggestResult) { return null; } const dataProductName = autosuggestResult.dataProductName; const dataProductDescription = autosuggestResult.dataProductDescription; return ( {dataProductName} {dataProductDescription && ( {dataProductDescription} )} ); }} renderInput={(params) => ( {loadingSuggestions && ( )} {params.InputProps.endAdornment} {showSettings && ( setSearchMenuAnchorEl(event.currentTarget) } title="Search settings" className="legend-marketplace__search-bar__settings-icon" > )} > ), }, }} /> )} /> {showSettings && ( setSearchMenuAnchorEl(null)} > {SEARCH_MODE_OPTIONS.map((option) => ( { setSearchMode( isEnabled ? mode : MarketplaceSearchMode.DATA_SPACES, ); LegendMarketplaceTelemetryHelper.logEvent_ToggleSearchMode( applicationStore.telemetryService, mode, isEnabled, ); }} /> ))} )} ); }, );