Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions changelogs/fragments/10789.yml
Original file line number Diff line number Diff line change
@@ -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))
2 changes: 2 additions & 0 deletions src/core/public/chrome/chrome_service.mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
2 changes: 1 addition & 1 deletion src/core/public/chrome/chrome_service.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 [];
},
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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();
});
});
158 changes: 151 additions & 7 deletions src/core/public/chrome/global_search/global_search_service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
*/
Expand All @@ -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<ReactNode[]>;

/**
* 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<ReactNode[]>;
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 [<SearchResult key="1">Result 1</SearchResult>];
* }
* });
* ```
*/
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<GlobalSearchCommand[]>;
/**
* 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 [<SearchResult key="1">Result 1</SearchResult>];
* }
* });
* ```
*/
registerSearchCommand(searchCommand: GlobalSearchCommand): void;
}

/**
Expand All @@ -76,7 +213,11 @@ export interface GlobalSearchServiceStartContract {
* @experimental
*/
export class GlobalSearchService {
private searchCommands = [] as GlobalSearchCommand[];
private searchCommands$ = new BehaviorSubject<GlobalSearchCommand[]>([]);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: why did we change it observable?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need to register some search commands in the start phase. I've expose a registerSearchCommands method in the start contract.


private get searchCommands() {
return this.searchCommands$.getValue();
}

private registerSearchCommand(searchHandler: GlobalSearchCommand) {
const exists = this.searchCommands.find((item) => {
Expand All @@ -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),
Expand All @@ -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),
};
}
}
Loading
Loading