diff --git a/changelogs/fragments/10789.yml b/changelogs/fragments/10789.yml new file mode 100644 index 000000000000..929f664a507b --- /dev/null +++ b/changelogs/fragments/10789.yml @@ -0,0 +1,3 @@ +feat: +- Add assets search command to find dashboards and visualizations from global search ([#10789](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/10789)) +- Enhance global submit commands for Enter-key triggered actions in global search ([#10789](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/10789)) \ No newline at end of file diff --git a/src/core/public/chrome/chrome_service.mock.ts b/src/core/public/chrome/chrome_service.mock.ts index 9eb122518241..b33f8c5daa29 100644 --- a/src/core/public/chrome/chrome_service.mock.ts +++ b/src/core/public/chrome/chrome_service.mock.ts @@ -92,6 +92,8 @@ const createStartContractMock = () => { globalSearch: { getAllSearchCommands: jest.fn(() => []), unregisterSearchCommand: jest.fn(), + getAllSearchCommands$: jest.fn(() => new BehaviorSubject([])), + registerSearchCommand: jest.fn(), }, setAppTitle: jest.fn(), setIsVisible: jest.fn(), diff --git a/src/core/public/chrome/chrome_service.tsx b/src/core/public/chrome/chrome_service.tsx index e2e5ec878cb7..9daa5bae89be 100644 --- a/src/core/public/chrome/chrome_service.tsx +++ b/src/core/public/chrome/chrome_service.tsx @@ -432,7 +432,7 @@ export class ChromeService { workspaceList$={workspaces.workspaceList$} currentWorkspace$={workspaces.currentWorkspace$} useUpdatedHeader={this.useUpdatedHeader} - globalSearchCommands={globalSearch.getAllSearchCommands()} + globalSearchCommands$={globalSearch.getAllSearchCommands$()} globalBanner$={this.globalBanner$.pipe(takeUntil(this.stop$))} keyboardShortcut={keyboardShortcut} /> diff --git a/src/core/public/chrome/global_search/global_search_service.test.ts b/src/core/public/chrome/global_search/global_search_service.test.ts index e2859e0609f5..6229900e349d 100644 --- a/src/core/public/chrome/global_search/global_search_service.test.ts +++ b/src/core/public/chrome/global_search/global_search_service.test.ts @@ -12,9 +12,14 @@ describe('GlobalSearchService', () => { const setup = globalSearchService.setup(); const start = globalSearchService.start(); + const mockAction = jest.fn(); + const customPlaceholder = 'Search for pages...'; + setup.registerSearchCommand({ id: 'test1', type: 'PAGES', + inputPlaceholder: customPlaceholder, + action: mockAction, run: async (query) => { return []; }, @@ -23,6 +28,12 @@ describe('GlobalSearchService', () => { expect(start.getAllSearchCommands()).toHaveLength(1); expect(start.getAllSearchCommands()[0].id).toEqual('test1'); expect(start.getAllSearchCommands()[0].type).toEqual('PAGES'); + expect(start.getAllSearchCommands()[0].inputPlaceholder).toEqual(customPlaceholder); + expect(start.getAllSearchCommands()[0].action).toBeDefined(); + + // Test that action can be called with payload + start.getAllSearchCommands()[0].action?.({ content: 'test query' }); + expect(mockAction).toHaveBeenCalledWith({ content: 'test query' }); }); it('unregisterSearchCommand', async () => { @@ -69,4 +80,66 @@ describe('GlobalSearchService', () => { expect(start.getAllSearchCommands()[0].id).toEqual('test2'); expect(start.getAllSearchCommands()[0].type).toEqual('PAGES'); }); + + it('registerSearchCommand with action callback', async () => { + const globalSearchService = new GlobalSearchService(); + const setup = globalSearchService.setup(); + const start = globalSearchService.start(); + + const mockAction = jest.fn(); + + setup.registerSearchCommand({ + id: 'test-action', + type: 'ACTIONS', + run: async (query) => { + return []; + }, + action: mockAction, + }); + + const commands = start.getAllSearchCommands(); + expect(commands).toHaveLength(1); + expect(commands[0].action).toBeDefined(); + + // Test that action can be called with payload + commands[0].action?.({ content: 'test query' }); + expect(mockAction).toHaveBeenCalledWith({ content: 'test query' }); + }); + + it('getAllSearchCommands$', async () => { + const globalSearchService = new GlobalSearchService(); + const setup = globalSearchService.setup(); + const start = globalSearchService.start(); + + const commands$ = start.getAllSearchCommands$(); + const receivedCommands: any[] = []; + + const subscription = commands$.subscribe((commands) => { + receivedCommands.push(commands); + }); + + // Initially should have empty array + expect(receivedCommands[0]).toHaveLength(0); + + // Register a command + setup.registerSearchCommand({ + id: 'test-observable', + type: 'PAGES', + run: async (query) => { + return []; + }, + }); + + // Should receive updated commands + expect(receivedCommands[1]).toHaveLength(1); + expect(receivedCommands[1][0].id).toEqual('test-observable'); + + // Unregister the command + start.unregisterSearchCommand('test-observable'); + + // Should receive empty array again + expect(receivedCommands[2]).toHaveLength(0); + + subscription.unsubscribe(); + }); }); diff --git a/src/core/public/chrome/global_search/global_search_service.ts b/src/core/public/chrome/global_search/global_search_service.ts index a7e41762ba37..0762f58f01c5 100644 --- a/src/core/public/chrome/global_search/global_search_service.ts +++ b/src/core/public/chrome/global_search/global_search_service.ts @@ -5,6 +5,7 @@ import { ReactNode } from 'react'; import { i18n } from '@osd/i18n'; +import { BehaviorSubject, Observable } from 'rxjs'; /** * search input match with `@` will handled by saved objects search command @@ -24,10 +25,27 @@ export const SearchCommandTypes = { }), alias: SAVED_OBJECTS_SYMBOL, }, + ACTIONS: { + description: i18n.translate('core.globalSearch.actions.description', { + defaultMessage: 'Actions', + }), + alias: null, + }, } as const; export type SearchCommandKeyTypes = keyof typeof SearchCommandTypes; +/** + * Options for the run method of GlobalSearchCommand + * @experimental + */ +export interface GlobalSearchCommandRunOptions { + /** + * AbortSignal to cancel the search operation + */ + abortSignal?: AbortSignal; +} + /** * @experimental */ @@ -41,21 +59,140 @@ export interface GlobalSearchCommand { * @type {SearchCommandTypes} */ type: SearchCommandKeyTypes; + + /** + * Defines the placeholder text displayed in the global search input field. + * When multiple commands specify a placeholder, only the first registered command's placeholder will be used. + * + * @example 'Search pages, assets, and actions...' + */ + inputPlaceholder?: string; + /** * do the search and return search result with a React element * @param value search query * @param callback callback function when search is done + * @param options options object containing abortSignal and other future extensible properties + */ + run( + value: string, + callback?: () => void, + options?: GlobalSearchCommandRunOptions + ): Promise; + + /** + * Callback function executed when the user presses Enter in the global search bar. + * This allows commands to perform custom actions based on the search query, such as navigation or triggering specific functionality. + * + * @param payload - The payload object containing the search content + * @param payload.content - The search query string entered by the user + * + * @example + * ```typescript + * action: ({ content }) => { + * // Navigate to search results page + * window.location.href = `/search?q=${encodeURIComponent(content)}`; + * } + * ``` */ - run(value: string, callback?: () => void): Promise; + action?: (payload: { content: string }) => void; } +/** + * Setup contract for the global search service. + * Provides methods to register search commands and submit commands during the setup lifecycle. + * @experimental + */ export interface GlobalSearchServiceSetupContract { + /** + * Registers a search command that will be executed when users perform searches in the global search bar. + * Each command must have a unique ID and will be invoked based on the search query pattern. + * + * @param searchCommand - The search command to register + * @throws Warning if a command with the same ID already exists + * + * @example + * ```typescript + * chrome.globalSearch.registerSearchCommand({ + * id: 'my-search-command', + * type: 'PAGES', + * run: async (query, callback, abortSignal) => { + * // Perform search logic + * return [Result 1]; + * } + * }); + * ``` + */ registerSearchCommand(searchCommand: GlobalSearchCommand): void; } +/** + * Start contract for the global search service. + * Provides methods to retrieve and manage search commands during the start lifecycle. + * @experimental + */ export interface GlobalSearchServiceStartContract { + /** + * Retrieves all registered search commands. + * Returns an array of all search commands that have been registered during the setup phase. + * + * @returns An array of all registered GlobalSearchCommand instances + * + * @example + * ```typescript + * const commands = chrome.globalSearch.getAllSearchCommands(); + * console.log(`Total commands: ${commands.length}`); + * ``` + */ getAllSearchCommands(): GlobalSearchCommand[]; + + /** + * Unregisters a previously registered search command by its ID. + * This removes the command from the list of available search commands. + * + * @param id - The unique identifier of the search command to unregister + * + * @example + * ```typescript + * chrome.globalSearch.unregisterSearchCommand('my-search-command'); + * ``` + */ unregisterSearchCommand(id: string): void; + + /** + * Returns an observable stream of all registered search commands. + * Subscribers will receive updates whenever search commands are added or removed. + * + * @returns An Observable that emits the current array of GlobalSearchCommand instances + * + * @example + * ```typescript + * chrome.globalSearch.getAllSearchCommands$().subscribe(commands => { + * console.log(`Available commands: ${commands.length}`); + * }); + * ``` + */ + getAllSearchCommands$: () => Observable; + /** + * Registers a search command that will be executed when users perform searches in the global search bar. + * Each command must have a unique ID and will be invoked based on the search query pattern. + * + * @param searchCommand - The search command to register + * @throws Warning if a command with the same ID already exists + * + * @example + * ```typescript + * chrome.globalSearch.registerSearchCommand({ + * id: 'my-search-command', + * type: 'PAGES', + * run: async (query, callback, abortSignal) => { + * // Perform search logic + * return [Result 1]; + * } + * }); + * ``` + */ + registerSearchCommand(searchCommand: GlobalSearchCommand): void; } /** @@ -76,7 +213,11 @@ export interface GlobalSearchServiceStartContract { * @experimental */ export class GlobalSearchService { - private searchCommands = [] as GlobalSearchCommand[]; + private searchCommands$ = new BehaviorSubject([]); + + private get searchCommands() { + return this.searchCommands$.getValue(); + } private registerSearchCommand(searchHandler: GlobalSearchCommand) { const exists = this.searchCommands.find((item) => { @@ -87,15 +228,16 @@ export class GlobalSearchService { console.warn(`Duplicate SearchCommands id ${searchHandler.id} found`); return; } - this.searchCommands.push(searchHandler); + this.searchCommands$.next([...this.searchCommands, searchHandler]); } private unregisterSearchCommand(id: string) { - this.searchCommands = this.searchCommands.filter((item) => { - return item.id !== id; - }); + this.searchCommands$.next( + this.searchCommands.filter((item) => { + return item.id !== id; + }) + ); } - public setup(): GlobalSearchServiceSetupContract { return { registerSearchCommand: this.registerSearchCommand.bind(this), @@ -106,6 +248,8 @@ export class GlobalSearchService { return { getAllSearchCommands: () => this.searchCommands, unregisterSearchCommand: this.unregisterSearchCommand.bind(this), + getAllSearchCommands$: () => this.searchCommands$.asObservable(), + registerSearchCommand: this.registerSearchCommand.bind(this), }; } } diff --git a/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav_group_enabled.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav_group_enabled.test.tsx.snap index b65c2335a046..a4dea5b165b4 100644 --- a/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav_group_enabled.test.tsx.snap +++ b/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav_group_enabled.test.tsx.snap @@ -22,7 +22,38 @@ exports[` should render correctly 1`] = `
+ > +
+
+ +
+ + +
+
+
+
should render correctly 2`] = ` >
+ > +
+
+ + + +
+
+
@@ -120,7 +177,38 @@ exports[` should show use case nav when current na
+ > +
+
+ +
+ + +
+
+
+
', () => { } }, capabilities: { ...capabilitiesServiceMock.createStartContract().capabilities }, + globalSearchCommands$: new BehaviorSubject([]), ...props, }; } diff --git a/src/core/public/chrome/ui/header/collapsible_nav_group_enabled.tsx b/src/core/public/chrome/ui/header/collapsible_nav_group_enabled.tsx index b5adac4866d1..a4015bfabd69 100644 --- a/src/core/public/chrome/ui/header/collapsible_nav_group_enabled.tsx +++ b/src/core/public/chrome/ui/header/collapsible_nav_group_enabled.tsx @@ -49,7 +49,7 @@ export interface CollapsibleNavGroupEnabledProps { setCurrentNavGroup: ChromeNavGroupServiceStartContract['setCurrentNavGroup']; capabilities: InternalApplicationStart['capabilities']; currentWorkspace$: WorkspacesStart['currentWorkspace$']; - globalSearchCommands?: GlobalSearchCommand[]; + globalSearchCommands$: Rx.Observable; } const titleForSeeAll = i18n.translate('core.ui.primaryNav.seeAllLabel', { @@ -73,7 +73,6 @@ export function CollapsibleNavGroupEnabled({ setCurrentNavGroup, capabilities, collapsibleNavHeaderRender, - globalSearchCommands, ...observables }: CollapsibleNavGroupEnabledProps) { const allNavLinks = useObservable(observables.navLinks$, []); @@ -83,6 +82,7 @@ export function CollapsibleNavGroupEnabled({ const navGroupsMap = useObservable(observables.navGroupsMap$, {}); const currentNavGroup = useObservable(observables.currentNavGroup$, undefined); const currentWorkspace = useObservable(observables.currentWorkspace$); + const globalSearchCommands = useObservable(observables.globalSearchCommands$); const visibleUseCases = useMemo(() => getVisibleUseCases(navGroupsMap), [navGroupsMap]); diff --git a/src/core/public/chrome/ui/header/header.test.tsx b/src/core/public/chrome/ui/header/header.test.tsx index 29368a08d3ee..dfca8e9e18b8 100644 --- a/src/core/public/chrome/ui/header/header.test.tsx +++ b/src/core/public/chrome/ui/header/header.test.tsx @@ -96,6 +96,7 @@ function mockProps() { workspaceList$: new BehaviorSubject([]), currentWorkspace$: new BehaviorSubject(null), useUpdatedHeader: false, + globalSearchCommands$: new BehaviorSubject([]), }; } diff --git a/src/core/public/chrome/ui/header/header.tsx b/src/core/public/chrome/ui/header/header.tsx index b4faf3fd5dce..1c54c729083f 100644 --- a/src/core/public/chrome/ui/header/header.tsx +++ b/src/core/public/chrome/ui/header/header.tsx @@ -131,9 +131,9 @@ export interface HeaderProps { workspaceList$: Observable; currentWorkspace$: WorkspacesStart['currentWorkspace$']; useUpdatedHeader?: boolean; - globalSearchCommands?: GlobalSearchCommand[]; globalBanner$?: Observable; keyboardShortcut?: KeyboardShortcutStart; + globalSearchCommands$: Observable; } const hasValue = (value: any) => { @@ -159,7 +159,6 @@ export function Header({ navGroupEnabled, setCurrentNavGroup, useUpdatedHeader, - globalSearchCommands, keyboardShortcut, ...observables }: HeaderProps) { @@ -716,7 +715,7 @@ export function Header({ setCurrentNavGroup={setCurrentNavGroup} capabilities={application.capabilities} currentWorkspace$={observables.currentWorkspace$} - globalSearchCommands={globalSearchCommands} + globalSearchCommands$={observables.globalSearchCommands$} /> ) : ( ', () => { expect(queryByText('saved objects')).toBeInTheDocument(); }); }); + + it('should call onSearchResultClick callback when provided', async () => { + const onSearchResultClick = jest.fn(); + const mockSearchFn = jest.fn().mockImplementation((query, callback) => { + callback(); + return Promise.resolve([result]); + }); + + const commands: GlobalSearchCommand[] = [ + { + id: 'test', + type: 'PAGES', + run: mockSearchFn, + }, + ]; + + const { getByTestId } = render( + + ); + + act(() => { + fireEvent.change(getByTestId('global-search-input'), { + target: { value: 'test' }, + }); + }); + + await waitFor(() => { + expect(mockSearchFn).toHaveBeenCalled(); + expect(onSearchResultClick).toHaveBeenCalled(); + }); + }); + + it('should abort previous search requests when new search is triggered', async () => { + const abortedSearchFn = jest.fn().mockImplementation((query, callback, options) => { + return new Promise((resolve) => { + setTimeout(() => { + if (!options?.abortSignal?.aborted) { + resolve([slow result]); + } + }, 100); + }); + }); + + jest.fn().mockResolvedValue([fast result]); + + const commands: GlobalSearchCommand[] = [ + { + id: 'slow', + type: 'PAGES', + run: abortedSearchFn, + }, + ]; + + const { getByTestId } = render(); + + const searchInput = getByTestId('global-search-input'); + + // Trigger first search + act(() => { + fireEvent.change(searchInput, { + target: { value: 'first' }, + }); + }); + + // Quickly trigger second search + act(() => { + fireEvent.change(searchInput, { + target: { value: 'second' }, + }); + }); + + await waitFor(() => { + expect(abortedSearchFn).toHaveBeenCalledTimes(2); + }); + }); + + it('should clear results when search value is empty', async () => { + const { getByTestId, queryByText } = render( + + ); + + searchFn.mockResolvedValue([test result]); + + // First, perform a search + act(() => { + fireEvent.change(getByTestId('global-search-input'), { + target: { value: 'test' }, + }); + }); + + await waitFor(() => { + expect(queryByText('test result')).toBeInTheDocument(); + }); + + // Clear the search + act(() => { + fireEvent.change(getByTestId('global-search-input'), { + target: { value: '' }, + }); + }); + + await waitFor(() => { + expect(queryByText('test result')).not.toBeInTheDocument(); + }); + }); + + it('should trigger action commands when Enter key is pressed', async () => { + const actionFn = jest.fn(); + const commandsWithAction: GlobalSearchCommand[] = [ + { + id: 'action-command', + type: 'ACTIONS', + run: jest.fn().mockResolvedValue([]), + action: actionFn, + }, + { + id: 'regular-command', + type: 'PAGES', + run: searchFn, + }, + ]; + + const { getByTestId } = render( + + ); + + const searchInput = getByTestId('global-search-input'); + + // Type in the search input + act(() => { + fireEvent.change(searchInput, { + target: { value: 'test query' }, + }); + }); + + // Press Enter key + act(() => { + fireEvent.keyDown(searchInput, { + key: 'Enter', + code: 'Enter', + }); + }); + act(() => { + fireEvent.keyUp(searchInput, { + key: 'Enter', + code: 'Enter', + }); + }); + + await waitFor(() => { + expect(actionFn).toHaveBeenCalledWith({ + content: 'test query', + }); + }); + }); + + it('should display custom input placeholder from commands', () => { + const customPlaceholder = 'Search for custom items'; + const commandsWithPlaceholder: GlobalSearchCommand[] = [ + { + id: 'custom', + type: 'PAGES', + run: searchFn, + inputPlaceholder: customPlaceholder, + }, + ]; + + const { getByTestId } = render( + + ); + + const searchInput = getByTestId('global-search-input'); + expect(searchInput).toHaveAttribute('placeholder', customPlaceholder); + }); + + it('should include ACTIONS type commands in filtered results', async () => { + const actionSearchFn = jest.fn().mockResolvedValue([action result]); + const commandsWithActions: GlobalSearchCommand[] = [ + { + id: 'page-command', + type: 'PAGES', + run: searchFn, + }, + { + id: 'action-command', + type: 'ACTIONS', + run: actionSearchFn, + }, + ]; + + const { getByTestId, queryByText } = render( + + ); + + searchFn.mockResolvedValue([page result]); + + act(() => { + fireEvent.change(getByTestId('global-search-input'), { + target: { value: 'test' }, + }); + }); + + await waitFor(() => { + // Both PAGES and ACTIONS commands should be called + expect(searchFn).toHaveBeenCalled(); + expect(actionSearchFn).toHaveBeenCalled(); + expect(queryByText('page result')).toBeInTheDocument(); + expect(queryByText('action result')).toBeInTheDocument(); + }); + }); }); diff --git a/src/core/public/chrome/ui/header/header_search_bar.tsx b/src/core/public/chrome/ui/header/header_search_bar.tsx index 5c94dd28af03..29f7b40f964a 100644 --- a/src/core/public/chrome/ui/header/header_search_bar.tsx +++ b/src/core/public/chrome/ui/header/header_search_bar.tsx @@ -86,27 +86,36 @@ export const HeaderSearchBar = ({ globalSearchCommands, panel, onSearchResultCli const [results, setResults] = useState([] as React.JSX.Element[]); const [isLoading, setIsLoading] = useState(false); const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const [searchValue, setSearchValue] = useState(''); + const enterKeyDownRef = useRef(false); + const searchBarInputRef = useRef(null); + const ongoingAbortControllersRef = useRef>( + [] + ); const closePopover = () => { setIsPopoverOpen(false); setResults([]); + setSearchValue(''); }; - const resultSection = (items: ReactNode[], sectionHeader: string) => { + const resultSection = (items: ReactNode[], sectionHeader: string | undefined) => { return ( - - - - - {sectionHeader} - - - + + {sectionHeader && ( + + + + {sectionHeader} + + + + )} {items.length ? ( {items.map((item, index) => ( - + ))} ) : ( @@ -121,51 +130,74 @@ export const HeaderSearchBar = ({ globalSearchCommands, panel, onSearchResultCli ); }; - const searchResultSections = - results && results.length ? ( - - {results.map((result) => ( - {result} - ))} - - ) : ( - - {i18n.translate('core.globalSearch.emptyResult.description', { - defaultMessage: 'No results found.', - })} - - ); + const searchResultSections = ( + <> + {results && results.length ? ( + + {results.map((result) => ( + {result} + ))} + + ) : ( + + {i18n.translate('core.globalSearch.emptyResult.description', { + defaultMessage: 'No results found.', + })} + + )} + + ); const onSearch = useCallback( async (value: string) => { - const filteredCommands = globalSearchCommands.filter((command) => { + const abortController = new AbortController(); + ongoingAbortControllersRef.current.push({ controller: abortController, query: value }); + if (enterKeyDownRef.current) { + globalSearchCommands + .filter((item) => !!item.action) + .forEach((command) => { + command.action?.({ + content: value, + }); + }); + enterKeyDownRef.current = false; + setIsPopoverOpen(false); + setSearchValue(''); + searchBarInputRef.current?.blur(); + return; + } + const commandsWithoutActions = globalSearchCommands.filter( + (command) => command.type !== 'ACTIONS' + ); + const filteredCommands = commandsWithoutActions.filter((command) => { const alias = SearchCommandTypes[command.type].alias; return alias && value.startsWith(alias); }); - - const defaultSearchCommands = globalSearchCommands.filter((command) => { + const defaultSearchCommands = commandsWithoutActions.filter((command) => { return !SearchCommandTypes[command.type].alias; }); - if (filteredCommands.length === 0) { filteredCommands.push(...defaultSearchCommands); } + filteredCommands.push( + ...globalSearchCommands.filter((command) => command.type === 'ACTIONS') + ); if (value && filteredCommands && filteredCommands.length) { setIsPopoverOpen(true); setIsLoading(true); - const settleResults = await Promise.allSettled( filteredCommands.map((command) => { const callback = onSearchResultClick || closePopover; const alias = SearchCommandTypes[command.type].alias; const queryValue = alias ? value.replace(alias, '').trim() : value; - return command.run(queryValue, callback).then((items) => { - return { items, type: command.type }; - }); + return command + .run(queryValue, callback, { abortSignal: abortController.signal }) + .then((items) => { + return { items, type: command.type }; + }); }) ); - const searchResults = settleResults .filter((result) => result.status === 'fulfilled') .map( @@ -181,14 +213,23 @@ export const HeaderSearchBar = ({ globalSearchCommands, panel, onSearchResultCli [type]: (acc[type] || []).concat(items), }; }, {} as Record); - const sections = Object.entries(searchResults).map(([key, items]) => { const sectionHeader = SearchCommandTypes[key as SearchCommandKeyTypes].description; - return resultSection(items, sectionHeader); + return resultSection(items, key !== 'ACTIONS' ? sectionHeader : undefined); }); - + if (abortController.signal.aborted) { + return; + } setIsLoading(false); setResults(sections); + // Abort previous search requests + do { + const currentItem = ongoingAbortControllersRef.current.shift(); + if (currentItem?.controller === abortController) { + break; + } + currentItem?.controller?.abort('Previous search results filled'); + } while (ongoingAbortControllersRef.current.length > 0); } else { setResults([]); } @@ -202,9 +243,12 @@ export const HeaderSearchBar = ({ globalSearchCommands, panel, onSearchResultCli incremental onSearch={onSearch} fullWidth - placeholder={i18n.translate('core.globalSearch.input.placeholder', { - defaultMessage: 'Search the menu', - })} + placeholder={ + globalSearchCommands.find((item) => item.inputPlaceholder)?.inputPlaceholder ?? + i18n.translate('core.globalSearch.input.placeholder', { + defaultMessage: 'Search menu or assets', + }) + } isLoading={isLoading} aria-label="Search the menus" data-test-subj="global-search-input" @@ -212,6 +256,19 @@ export const HeaderSearchBar = ({ globalSearchCommands, panel, onSearchResultCli onFocus={() => { setIsPopoverOpen(true); }} + inputRef={(input) => { + searchBarInputRef.current = input; + }} + style={{ paddingRight: 32 }} + value={searchValue} + onKeyDown={(e) => { + if (e.key === 'Enter') { + enterKeyDownRef.current = true; + } + }} + onChange={(e) => { + setSearchValue(e.currentTarget.value); + }} /> ); @@ -231,28 +288,28 @@ export const HeaderSearchBar = ({ globalSearchCommands, panel, onSearchResultCli if (panel) { return searchBarPanel; - } else { - return ( - <> - {!isPopoverOpen && searchBar} - {isPopoverOpen && ( - } - zIndex={2000} - panelPaddingSize="s" - attachToAnchor={true} - ownFocus={true} - display="block" - isOpen={isPopoverOpen} - closePopover={() => { - closePopover(); - }} - > - {searchBarPanel} - - )} - - ); } + + return ( + <> + {!isPopoverOpen && searchBar} + {isPopoverOpen && ( + } + zIndex={2000} + panelPaddingSize="s" + attachToAnchor={true} + ownFocus={true} + display="block" + isOpen={isPopoverOpen} + closePopover={() => { + closePopover(); + }} + > + {searchBarPanel} + + )} + + ); }; diff --git a/src/plugins/dashboard/public/application/components/dashboard_top_nav/__snapshots__/dashboard_top_nav.test.tsx.snap b/src/plugins/dashboard/public/application/components/dashboard_top_nav/__snapshots__/dashboard_top_nav.test.tsx.snap index e96945aa19d7..58cd62d15b1b 100644 --- a/src/plugins/dashboard/public/application/components/dashboard_top_nav/__snapshots__/dashboard_top_nav.test.tsx.snap +++ b/src/plugins/dashboard/public/application/components/dashboard_top_nav/__snapshots__/dashboard_top_nav.test.tsx.snap @@ -147,6 +147,8 @@ exports[`Dashboard top nav render in embed mode 1`] = ` "getIsVisible$": [MockFunction], "globalSearch": Object { "getAllSearchCommands": [MockFunction], + "getAllSearchCommands$": [MockFunction], + "registerSearchCommand": [MockFunction], "unregisterSearchCommand": [MockFunction], }, "logos": Object { @@ -1317,6 +1319,8 @@ exports[`Dashboard top nav render in embed mode, and force hide filter bar 1`] = "getIsVisible$": [MockFunction], "globalSearch": Object { "getAllSearchCommands": [MockFunction], + "getAllSearchCommands$": [MockFunction], + "registerSearchCommand": [MockFunction], "unregisterSearchCommand": [MockFunction], }, "logos": Object { @@ -2487,6 +2491,8 @@ exports[`Dashboard top nav render in embed mode, components can be forced show b "getIsVisible$": [MockFunction], "globalSearch": Object { "getAllSearchCommands": [MockFunction], + "getAllSearchCommands$": [MockFunction], + "registerSearchCommand": [MockFunction], "unregisterSearchCommand": [MockFunction], }, "logos": Object { @@ -3657,6 +3663,8 @@ exports[`Dashboard top nav render in full screen mode with appended URL param bu "getIsVisible$": [MockFunction], "globalSearch": Object { "getAllSearchCommands": [MockFunction], + "getAllSearchCommands$": [MockFunction], + "registerSearchCommand": [MockFunction], "unregisterSearchCommand": [MockFunction], }, "logos": Object { @@ -4827,6 +4835,8 @@ exports[`Dashboard top nav render in full screen mode, no componenets should be "getIsVisible$": [MockFunction], "globalSearch": Object { "getAllSearchCommands": [MockFunction], + "getAllSearchCommands$": [MockFunction], + "registerSearchCommand": [MockFunction], "unregisterSearchCommand": [MockFunction], }, "logos": Object { @@ -5997,6 +6007,8 @@ exports[`Dashboard top nav render with all components 1`] = ` "getIsVisible$": [MockFunction], "globalSearch": Object { "getAllSearchCommands": [MockFunction], + "getAllSearchCommands$": [MockFunction], + "registerSearchCommand": [MockFunction], "unregisterSearchCommand": [MockFunction], }, "logos": Object { diff --git a/src/plugins/workspace/public/components/global_search/constants.ts b/src/plugins/workspace/public/components/global_search/constants.ts new file mode 100644 index 000000000000..1a9074145229 --- /dev/null +++ b/src/plugins/workspace/public/components/global_search/constants.ts @@ -0,0 +1,19 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Supported asset types for global search + */ +export enum AssetType { + Dashboard = 'dashboard', + Visualization = 'visualization', +} + +/** + * Array of all supported asset types for API queries + */ +export const SUPPORTED_ASSET_TYPES: string[] = Object.values(AssetType); + +export type SupportedAssetType = AssetType; diff --git a/src/plugins/workspace/public/components/global_search/search_assets_command.test.ts b/src/plugins/workspace/public/components/global_search/search_assets_command.test.ts new file mode 100644 index 000000000000..52ac559d8b7d --- /dev/null +++ b/src/plugins/workspace/public/components/global_search/search_assets_command.test.ts @@ -0,0 +1,203 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { searchAssets } from './search_assets_command'; +import { HttpStart } from '../../../../../core/public'; +import { coreMock } from '../../../../../core/public/mocks'; +import { SavedObjectWithMetadata } from '../../../../saved_objects_management/common'; +import { AssetType, SUPPORTED_ASSET_TYPES } from './constants'; + +describe('searchAssets', () => { + let httpMock: jest.Mocked; + const mockBasePath = '/test-base-path'; + + beforeEach(() => { + const coreStart = coreMock.createStart(); + httpMock = coreStart.http as jest.Mocked; + httpMock.basePath.get = jest.fn(() => mockBasePath); + httpMock.basePath.prepend = jest.fn((path: string) => `${mockBasePath}${path}`); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + const createMockAsset = ( + id: string, + type: string, + title: string, + path: string, + workspaces?: string[] + ): SavedObjectWithMetadata => ({ + id, + type, + attributes: {}, + references: [], + meta: { + title, + inAppUrl: { + path, + uiCapabilitiesPath: '', + }, + }, + workspaces, + }); + + it('should return empty array when API call fails', async () => { + httpMock.get.mockRejectedValue(new Error('API Error')); + + const result = await searchAssets({ + http: httpMock, + query: 'test', + visibleWorkspaceIds: [], + }); + + expect(result).toEqual([]); + }); + + it('should call API with correct parameters and filter invalid assets', async () => { + const mockAssets = [ + createMockAsset('1', 'dashboard', 'Test Dashboard', '/app/dashboards/1'), + { + ...createMockAsset('2', 'visualization', '', '/app/visualize/2'), + meta: { + title: '', + inAppUrl: { + path: '/app/visualize/2', + uiCapabilitiesPath: '', + }, + }, + }, + ]; + + httpMock.get.mockResolvedValue({ + saved_objects: mockAssets, + }); + + const result = await searchAssets({ + http: httpMock, + query: 'dashboard', + visibleWorkspaceIds: [], + }); + + expect(httpMock.get).toHaveBeenCalledWith( + '/api/opensearch-dashboards/management/saved_objects/_find', + { + query: { + type: SUPPORTED_ASSET_TYPES, + search: '*dashboard*', + perPage: 10, + workspaces: [], + }, + signal: undefined, + } + ); + expect(result).toHaveLength(1); + }); + + it('should include currentWorkspaceId in API call when provided', async () => { + httpMock.get.mockResolvedValue({ + saved_objects: [], + }); + + const currentWorkspaceId = 'workspace-1'; + + await searchAssets({ + http: httpMock, + query: 'test', + currentWorkspaceId, + visibleWorkspaceIds: [], + }); + + expect(httpMock.get).toHaveBeenCalledWith( + '/api/opensearch-dashboards/management/saved_objects/_find', + { + query: { + type: SUPPORTED_ASSET_TYPES, + search: '*test*', + perPage: 10, + workspaces: [currentWorkspaceId], + }, + signal: undefined, + } + ); + }); + + it('should format URL with workspace ID when asset has workspaces', async () => { + const currentWorkspaceId = 'workspace-1'; + const mockAssets = [ + createMockAsset('1', 'dashboard', 'Test Dashboard', '/app/dashboards/1', [ + currentWorkspaceId, + ]), + ]; + + httpMock.get.mockResolvedValue({ + saved_objects: mockAssets, + }); + + const result = await searchAssets({ + http: httpMock, + query: 'test', + currentWorkspaceId, + visibleWorkspaceIds: [currentWorkspaceId], + }); + + expect(result).toHaveLength(1); + const breadcrumbProps = (result[0] as any).props; + expect(breadcrumbProps.breadcrumbs[1].href).toContain(currentWorkspaceId); + }); + + it('should use first visible workspace when no currentWorkspaceId provided', async () => { + const visibleWorkspaceIds = ['workspace-1', 'workspace-2']; + const mockAssets = [ + createMockAsset('1', 'dashboard', 'Test Dashboard', '/app/dashboards/1', [ + 'workspace-2', + 'workspace-3', + ]), + ]; + + httpMock.get.mockResolvedValue({ + saved_objects: mockAssets, + }); + + const result = await searchAssets({ + http: httpMock, + query: 'test', + visibleWorkspaceIds, + }); + + expect(result).toHaveLength(1); + const breadcrumbProps = (result[0] as any).props; + expect(breadcrumbProps.breadcrumbs[1].href).toContain('workspace-2'); + }); + + it('should call onAssetClick callback and replace management path', async () => { + const onAssetClick = jest.fn(); + const mockAssets = [ + createMockAsset( + '1', + 'dashboard', + 'Test Dashboard', + '/app/management/opensearch-dashboards/objects/dashboard/1' + ), + ]; + + httpMock.get.mockResolvedValue({ + saved_objects: mockAssets, + }); + + const result = await searchAssets({ + http: httpMock, + query: 'test', + visibleWorkspaceIds: [], + onAssetClick, + }); + + expect(result).toHaveLength(1); + const breadcrumbProps = (result[0] as any).props; + expect(breadcrumbProps.breadcrumbs[1].onClick).toBe(onAssetClick); + expect(breadcrumbProps.breadcrumbs[1].href).toBe(`${mockBasePath}/app/objects/dashboard/1`); + }); +}); diff --git a/src/plugins/workspace/public/components/global_search/search_assets_command.tsx b/src/plugins/workspace/public/components/global_search/search_assets_command.tsx new file mode 100644 index 000000000000..645e21ee9ef0 --- /dev/null +++ b/src/plugins/workspace/public/components/global_search/search_assets_command.tsx @@ -0,0 +1,111 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { EuiHighlight, EuiSimplifiedBreadcrumbs } from '@elastic/eui'; + +import type { ApplicationStart } from '../../../../../core/public'; +import { HttpStart, IBasePath } from '../../../../../core/public'; +import type { SavedObjectWithMetadata } from '../../../../saved_objects_management/common'; +import { formatUrlWithWorkspaceId } from '../../../../../core/public/utils'; +import { SUPPORTED_ASSET_TYPES } from './constants'; + +// TODO: Separate a util function to share with src/plugins/saved_objects_management/public/management_section/objects_table/components/table.tsx in the future +const getAssetsFinalPath = ({ + object, + useUpdatedUX, + basePath, + currentWorkspaceId, + visibleWorkspaceIds, +}: { + object: SavedObjectWithMetadata; + useUpdatedUX: boolean; + basePath: IBasePath; + currentWorkspaceId: string | undefined; + visibleWorkspaceIds: string[]; +}) => { + const { path = '' } = object.meta.inAppUrl || {}; + let finalPath = path; + if (useUpdatedUX && finalPath) { + finalPath = finalPath.replace(/^\/app\/management\/opensearch-dashboards/, '/app'); + } + let inAppUrl = basePath.prepend(finalPath); + if (object.workspaces?.length) { + if (currentWorkspaceId) { + inAppUrl = formatUrlWithWorkspaceId(finalPath, currentWorkspaceId, basePath); + } else { + // find first workspace user have permission + const workspaceId = object.workspaces.find((wsId) => visibleWorkspaceIds.includes(wsId)); + if (workspaceId) { + inAppUrl = formatUrlWithWorkspaceId(finalPath, workspaceId, basePath); + } + } + } + return inAppUrl; +}; + +export const searchAssets = async ({ + http, + query, + currentWorkspaceId, + abortSignal, + visibleWorkspaceIds, + onAssetClick, +}: { + http: HttpStart; + query: string; + application?: ApplicationStart; + currentWorkspaceId?: string; + abortSignal?: AbortSignal; + visibleWorkspaceIds: string[]; + onAssetClick?: () => void; +}) => { + let findResponse; + + try { + findResponse = await http.get>( + '/api/opensearch-dashboards/management/saved_objects/_find', + { + query: { + type: SUPPORTED_ASSET_TYPES, + search: `*${query}*`, + perPage: 10, + workspaces: currentWorkspaceId ? [currentWorkspaceId] : [], + }, + signal: abortSignal, + } + ); + } catch (e) { + return []; + } + + return findResponse.saved_objects + .map((asset) => { + if (!asset.meta.title || !asset.meta.inAppUrl?.path) { + return null; + } + return ( + {asset.meta.title}, + href: getAssetsFinalPath({ + object: asset, + basePath: http.basePath, + currentWorkspaceId, + useUpdatedUX: true, + visibleWorkspaceIds, + }), + onClick: onAssetClick, + }, + ]} + hideTrailingSeparator + responsive + /> + ); + }) + .filter((item) => !!item); +}; diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts index 0ab96c8b5ccb..81830a508c0b 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -7,6 +7,7 @@ import { BehaviorSubject, combineLatest, Subscription } from 'rxjs'; import React from 'react'; import { i18n } from '@osd/i18n'; import { map } from 'rxjs/operators'; +import { debounce } from 'lodash'; import { Plugin, CoreStart, @@ -73,6 +74,7 @@ import { registerDefaultCollaboratorTypes } from './register_default_collaborato import { WorkspaceValidationService } from './services/workspace_validation_service'; import { workspaceSearchPages } from './components/global_search/search_pages_command'; import { isNavGroupInFeatureConfigs } from '../../../core/public'; +import { searchAssets } from './components/global_search/search_assets_command'; type WorkspaceAppType = ( params: AppMountParameters, @@ -543,6 +545,33 @@ export class WorkspacePlugin workspaceSearchPages(query, this.registeredUseCases$, this.coreStart, callback), }); + let resolver: (payload: Awaited>) => void; + const debouncedSearchAssets = debounce((...args: Parameters) => { + searchAssets(...args).then(resolver); + }, 200); + + core.chrome.globalSearch.registerSearchCommand({ + id: 'assetsSearch', + type: 'SAVED_OBJECTS', + run: async (query: string, callback, options) => { + const [{ workspaces, http }] = await core.getStartServices(); + const currentWorkspaceId = workspaces.currentWorkspaceId$.getValue(); + const visibleWorkspaceIds = workspaces.workspaceList$.getValue().map(({ id }) => id); + + return new Promise((resolve) => { + resolver = resolve; + debouncedSearchAssets({ + http, + query, + currentWorkspaceId, + abortSignal: options?.abortSignal, + visibleWorkspaceIds, + onAssetClick: callback, + }); + }); + }, + }); + if (workspaceId) { core.chrome.registerCollapsibleNavHeader(() => { if (!this.coreStart) {