diff --git a/packages/grafana-data/src/index.ts b/packages/grafana-data/src/index.ts index 2c2f8750a47..34519950e15 100644 --- a/packages/grafana-data/src/index.ts +++ b/packages/grafana-data/src/index.ts @@ -590,6 +590,7 @@ export { type PluginExtensionDataSourceConfigActionsContext, type PluginExtensionDataSourceConfigStatusContext, type PluginExtensionCommandPaletteContext, + type DynamicPluginExtensionCommandPaletteContext, type PluginExtensionOpenModalOptions, type PluginExtensionExposedComponentConfig, type PluginExtensionAddedComponentConfig, diff --git a/packages/grafana-data/src/types/app.ts b/packages/grafana-data/src/types/app.ts index 5fc5a8a66b3..53066be39c7 100644 --- a/packages/grafana-data/src/types/app.ts +++ b/packages/grafana-data/src/types/app.ts @@ -156,7 +156,6 @@ export class AppPlugin extends GrafanaPlugin { * const response = await fetch(`/api/issues?q=${searchQuery}`, { signal }); @@ -165,14 +164,15 @@ export class AppPlugin extends GrafanaPlugin { + * helpers.openModal({ + * title: result.title, + * body: IssueDetailsModal, + * }); + * }, * })); * }, - * onSelect: (result, helpers) => { - * helpers.openModal({ - * title: result.title, - * body: IssueDetailsModal, - * }); - * }, * }); * ``` */ diff --git a/packages/grafana-data/src/types/pluginExtensions.ts b/packages/grafana-data/src/types/pluginExtensions.ts index 1bc5713d7c9..94720df2ca7 100644 --- a/packages/grafana-data/src/types/pluginExtensions.ts +++ b/packages/grafana-data/src/types/pluginExtensions.ts @@ -292,6 +292,17 @@ export type PluginExtensionCommandPaletteContext = { signal?: AbortSignal; }; +/** + * Context for dynamic command palette search providers. + * Unlike the base context, searchQuery and signal are always provided. + */ +export type DynamicPluginExtensionCommandPaletteContext = { + /** The current search query entered by the user */ + searchQuery: string; + /** Signal for request cancellation */ + signal: AbortSignal; +}; + export type PluginExtensionResourceAttributesContext = { // Key-value pairs of resource attributes, attribute name is the key attributes: Record; @@ -367,33 +378,35 @@ export type CommandPaletteDynamicResult = { section?: string; /** Optional custom data to pass through to the action handler */ data?: Record; + /** + * Action handler when this result is selected. + * If not provided, will use `path` for navigation. + */ + onSelect?: CommandPaletteDynamicResultAction; }; /** * Action handler for when a dynamic result is selected */ export type CommandPaletteDynamicResultAction = ( - result: CommandPaletteDynamicResult, - helpers: PluginExtensionEventHelpers + result: Omit, + helpers: PluginExtensionEventHelpers ) => void | Promise; /** * Search provider function that fetches dynamic results */ export type CommandPaletteDynamicSearchProvider = ( - context: PluginExtensionCommandPaletteContext + context: DynamicPluginExtensionCommandPaletteContext ) => Promise; /** * Configuration for registering a dynamic command palette provider */ export type PluginExtensionCommandPaletteDynamicConfig = { - /** Display name for this search provider */ - title: string; - /** - * Category/section name for grouping results - * @default Plugin ID + * Category/section name for grouping results. + * If not provided, results will be grouped under "Dynamic Results". */ category?: string; @@ -404,25 +417,10 @@ export type PluginExtensionCommandPaletteDynamicConfig = { minQueryLength?: number; /** - * Debounce delay in milliseconds - * @default 300 - */ - debounceMs?: number; - - /** - * Search provider function that returns results + * Search provider function that returns results. + * Return an empty array to skip results for the current search. + * To conditionally disable the provider, simply return an empty array + * based on your own logic instead of using a separate isActive filter. */ searchProvider: CommandPaletteDynamicSearchProvider; - - /** - * Action handler when a result is selected - * If not provided, will use result.path for navigation - */ - onSelect?: CommandPaletteDynamicResultAction; - - /** - * Optional filter to determine when this provider should be active - * Return false to disable this provider for the current context - */ - isActive?: (context: PluginExtensionCommandPaletteContext) => boolean; }; diff --git a/public/app/features/commandPalette/CommandPalette.tsx b/public/app/features/commandPalette/CommandPalette.tsx index 07c57d8dca8..a8a81b37b88 100644 --- a/public/app/features/commandPalette/CommandPalette.tsx +++ b/public/app/features/commandPalette/CommandPalette.tsx @@ -61,7 +61,16 @@ function CommandPaletteContents() { // Normally we register actions with kbar, and it knows not to show actions which are under a different parent than is // the currentRootActionId. Because these search results are manually added to the list later, they would show every // time. - const { searchResults, isFetchingSearchResults } = useSearchResults({ searchQuery, show: !currentRootActionId }); + const { searchResults: dashboardFolderResults, isFetchingSearchResults } = useSearchResults({ + searchQuery, + show: !currentRootActionId, + }); + + // Combine all search results (dashboard/folder results + dynamic plugin results) + const searchResults = useMemo( + () => [...dashboardFolderResults, ...dynamicResults], + [dashboardFolderResults, dynamicResults] + ); const ref = useRef(null); const { overlayProps } = useOverlay( @@ -95,9 +104,8 @@ function CommandPaletteContents() { {scopesRow ?
{scopesRow}
: null}
@@ -142,11 +150,10 @@ function AncestorBreadcrumbs() { interface RenderResultsProps { isFetchingSearchResults: boolean; searchResults: CommandPaletteAction[]; - dynamicResults: Array<{ section: string; items: ActionImpl[] }>; searchQuery: string; } -const RenderResults = ({ isFetchingSearchResults, searchResults, dynamicResults, searchQuery }: RenderResultsProps) => { +const RenderResults = ({ isFetchingSearchResults, searchResults, searchQuery }: RenderResultsProps) => { const { results: kbarResults, rootActionId } = useMatches(); const { query } = useKBar(); const { isAvailable: isAssistantAvailable } = useAssistant(); @@ -155,52 +162,65 @@ const RenderResults = ({ isFetchingSearchResults, searchResults, dynamicResults, const dashboardsSectionTitle = t('command-palette.section.dashboard-search-results', 'Dashboards'); const foldersSectionTitle = t('command-palette.section.folder-search-results', 'Folders'); - // because dashboard search results aren't registered as actions, we need to manually - // convert them to ActionImpls before passing them as items to KBarResults - const dashboardResultItems = useMemo( - () => - searchResults - .filter((item) => item.id.startsWith('go/dashboard')) - .map((dashboard) => new ActionImpl(dashboard, { store: {} })), - [searchResults] - ); - const folderResultItems = useMemo( - () => - searchResults - .filter((item) => item.id.startsWith('go/folder')) - .map((folder) => new ActionImpl(folder, { store: {} })), - [searchResults] - ); + + // Group search results by section (dashboard, folder, or dynamic plugin sections) + const groupedSearchResults = useMemo(() => { + const groups = new Map(); + + searchResults.forEach((item) => { + let section: string; + if (item.id.startsWith('go/dashboard')) { + section = dashboardsSectionTitle; + } else if (item.id.startsWith('go/folder')) { + section = foldersSectionTitle; + } else { + // Dynamic results have their section set + // Section can be a string or { name: string; priority: number; } + const itemSection = item.section; + section = + typeof itemSection === 'string' + ? itemSection + : typeof itemSection === 'object' && itemSection !== null + ? itemSection.name + : 'Dynamic Results'; + } + + if (!groups.has(section)) { + groups.set(section, []); + } + groups.get(section)!.push(new ActionImpl(item, { store: {} })); + }); + + return groups; + }, [searchResults, dashboardsSectionTitle, foldersSectionTitle]); const items = useMemo(() => { const results = [...kbarResults]; - // Add dynamic results from plugins (already filtered by searchProvider) - dynamicResults.forEach(({ section, items }) => { - if (items.length > 0) { + // Add all grouped search results (folders, dashboards, and dynamic results) + // Folders first, then dynamic results, then dashboards + const folderResults = groupedSearchResults.get(foldersSectionTitle) ?? []; + if (folderResults.length > 0) { + results.push(foldersSectionTitle); + results.push(...folderResults); + } + + // Add dynamic plugin results (any section that's not dashboard/folder) + groupedSearchResults.forEach((items, section) => { + if (section !== dashboardsSectionTitle && section !== foldersSectionTitle && items.length > 0) { results.push(section); results.push(...items); } }); - // Add dashboard and folder search results - if (folderResultItems.length > 0) { - results.push(foldersSectionTitle); - results.push(...folderResultItems); - } - if (dashboardResultItems.length > 0) { + const dashboardResults = groupedSearchResults.get(dashboardsSectionTitle) ?? []; + if (dashboardResults.length > 0) { results.push(dashboardsSectionTitle); - results.push(...dashboardResultItems); + results.push(...dashboardResults); } + return results; - }, [ - kbarResults, - dynamicResults, - dashboardsSectionTitle, - dashboardResultItems, - foldersSectionTitle, - folderResultItems, - ]); + }, [kbarResults, groupedSearchResults, dashboardsSectionTitle, foldersSectionTitle]); const showEmptyState = !isFetchingSearchResults && items.length === 0; useEffect(() => { diff --git a/public/app/features/commandPalette/CommandPaletteDynamicRegistry.test.ts b/public/app/features/commandPalette/CommandPaletteDynamicRegistry.test.ts index e6dc60c775b..0a7cf8899d6 100644 --- a/public/app/features/commandPalette/CommandPaletteDynamicRegistry.test.ts +++ b/public/app/features/commandPalette/CommandPaletteDynamicRegistry.test.ts @@ -23,7 +23,6 @@ describe('CommandPaletteDynamicRegistry', () => { pluginId, configs: [ { - title: 'Test Provider', searchProvider: mockSearchProvider, }, ], @@ -31,15 +30,13 @@ describe('CommandPaletteDynamicRegistry', () => { const state = await registry.getState(); expect(state).toEqual({ - [`${pluginId}/Test Provider`]: [ + [`${pluginId}/0`]: [ { pluginId, config: { - title: 'Test Provider', searchProvider: mockSearchProvider, - category: pluginId, + category: undefined, minQueryLength: 2, - debounceMs: 300, }, }, ], @@ -54,18 +51,16 @@ describe('CommandPaletteDynamicRegistry', () => { pluginId, configs: [ { - title: 'Test Provider', searchProvider: mockSearchProvider, }, ], }); const state = await registry.getState(); - const item = state[`${pluginId}/Test Provider`][0]; + const item = state[`${pluginId}/0`][0]; - expect(item.config.category).toBe(pluginId); + expect(item.config.category).toBeUndefined(); expect(item.config.minQueryLength).toBe(2); - expect(item.config.debounceMs).toBe(300); }); it('should preserve custom config values when provided', async () => { @@ -76,21 +71,18 @@ describe('CommandPaletteDynamicRegistry', () => { pluginId, configs: [ { - title: 'Custom Provider', searchProvider: mockSearchProvider, category: 'Custom Category', minQueryLength: 5, - debounceMs: 500, }, ], }); const state = await registry.getState(); - const item = state[`${pluginId}/Custom Provider`][0]; + const item = state[`${pluginId}/0`][0]; expect(item.config.category).toBe('Custom Category'); expect(item.config.minQueryLength).toBe(5); - expect(item.config.debounceMs).toBe(500); }); it('should register multiple providers from the same plugin', async () => { @@ -102,11 +94,9 @@ describe('CommandPaletteDynamicRegistry', () => { pluginId, configs: [ { - title: 'Provider 1', searchProvider: mockSearchProvider1, }, { - title: 'Provider 2', searchProvider: mockSearchProvider2, }, ], @@ -114,8 +104,8 @@ describe('CommandPaletteDynamicRegistry', () => { const state = await registry.getState(); expect(Object.keys(state)).toHaveLength(2); - expect(state[`${pluginId}/Provider 1`]).toBeDefined(); - expect(state[`${pluginId}/Provider 2`]).toBeDefined(); + expect(state[`${pluginId}/0`]).toBeDefined(); + expect(state[`${pluginId}/1`]).toBeDefined(); }); it('should notify subscribers when the registry changes', async () => { @@ -129,7 +119,6 @@ describe('CommandPaletteDynamicRegistry', () => { pluginId, configs: [ { - title: 'Test Provider', searchProvider: jest.fn().mockResolvedValue([]), }, ], @@ -149,7 +138,6 @@ describe('CommandPaletteDynamicRegistry', () => { pluginId, configs: [ { - title: 'Test', searchProvider: jest.fn(), }, ], @@ -166,7 +154,6 @@ describe('CommandPaletteDynamicRegistry', () => { pluginId, configs: [ { - title: 'Test', searchProvider: jest.fn(), }, ], @@ -194,7 +181,6 @@ describe('CommandPaletteDynamicRegistry', () => { pluginId, configs: [ { - title: 'Test Provider', searchProvider: jest.fn().mockResolvedValue([]), }, ], @@ -205,7 +191,7 @@ describe('CommandPaletteDynamicRegistry', () => { expect(Object.keys(readOnlyState)).toHaveLength(1); expect(subscribeCallback).toHaveBeenCalledTimes(2); // initial empty + registration - expect(Object.keys(subscribeCallback.mock.calls[1][0])).toEqual([`${pluginId}/Test Provider`]); + expect(Object.keys(subscribeCallback.mock.calls[1][0])).toEqual([`${pluginId}/0`]); }); }); @@ -220,44 +206,6 @@ describe('CommandPaletteDynamicRegistry', () => { consoleErrorSpy.mockRestore(); }); - it('should not register provider without title', async () => { - const registry = new CommandPaletteDynamicRegistry(); - - registry.register({ - pluginId, - configs: [ - { - // @ts-ignore - testing invalid config - title: '', - searchProvider: jest.fn(), - }, - ], - }); - - const state = await registry.getState(); - expect(Object.keys(state)).toHaveLength(0); - expect(consoleErrorSpy).toHaveBeenCalled(); - }); - - it('should not register provider with non-string title', async () => { - const registry = new CommandPaletteDynamicRegistry(); - - registry.register({ - pluginId, - configs: [ - { - // @ts-ignore - testing invalid config - title: 123, - searchProvider: jest.fn(), - }, - ], - }); - - const state = await registry.getState(); - expect(Object.keys(state)).toHaveLength(0); - expect(consoleErrorSpy).toHaveBeenCalled(); - }); - it('should not register provider without searchProvider', async () => { const registry = new CommandPaletteDynamicRegistry(); @@ -265,7 +213,6 @@ describe('CommandPaletteDynamicRegistry', () => { pluginId, configs: [ { - title: 'Test', // @ts-ignore - testing invalid config searchProvider: undefined, }, @@ -284,7 +231,6 @@ describe('CommandPaletteDynamicRegistry', () => { pluginId, configs: [ { - title: 'Test', // @ts-ignore - testing invalid config searchProvider: 'not-a-function', }, @@ -307,7 +253,6 @@ describe('CommandPaletteDynamicRegistry', () => { pluginId, configs: [ { - title: 'Test Provider', searchProvider: jest.fn().mockResolvedValue([]), }, ], @@ -315,7 +260,7 @@ describe('CommandPaletteDynamicRegistry', () => { await registry.getState(); expect(consoleLogSpy).toHaveBeenCalledWith( - expect.stringContaining('Registered provider: test-plugin/Test Provider') + expect.stringContaining('Registered provider: test-plugin/0') ); consoleLogSpy.mockRestore(); @@ -333,7 +278,6 @@ describe('CommandPaletteDynamicRegistry', () => { pluginId: 'plugin1', configs: [ { - title: 'Provider 1', searchProvider: mockSearchProvider1, }, ], @@ -343,7 +287,6 @@ describe('CommandPaletteDynamicRegistry', () => { pluginId: 'plugin2', configs: [ { - title: 'Provider 2', searchProvider: mockSearchProvider2, }, ], @@ -355,8 +298,19 @@ describe('CommandPaletteDynamicRegistry', () => { const results = await registry.search(context); - expect(mockSearchProvider1).toHaveBeenCalledWith(context); - expect(mockSearchProvider2).toHaveBeenCalledWith(context); + // Search providers receive DynamicPluginExtensionCommandPaletteContext with required fields + expect(mockSearchProvider1).toHaveBeenCalledWith( + expect.objectContaining({ + searchQuery: 'test query', + signal: expect.any(Object), + }) + ); + expect(mockSearchProvider2).toHaveBeenCalledWith( + expect.objectContaining({ + searchQuery: 'test query', + signal: expect.any(Object), + }) + ); expect(results.size).toBe(2); }); @@ -368,7 +322,6 @@ describe('CommandPaletteDynamicRegistry', () => { pluginId, configs: [ { - title: 'Test Provider', searchProvider: mockSearchProvider, }, ], @@ -398,7 +351,6 @@ describe('CommandPaletteDynamicRegistry', () => { pluginId, configs: [ { - title: 'Test Provider', searchProvider: mockSearchProvider, minQueryLength: 3, }, @@ -422,7 +374,6 @@ describe('CommandPaletteDynamicRegistry', () => { pluginId, configs: [ { - title: 'Test Provider', searchProvider: mockSearchProvider, minQueryLength: 2, }, @@ -438,58 +389,6 @@ describe('CommandPaletteDynamicRegistry', () => { expect(mockSearchProvider).toHaveBeenCalled(); }); - it('should skip inactive providers', async () => { - const registry = new CommandPaletteDynamicRegistry(); - const mockSearchProvider = jest.fn().mockResolvedValue([]); - const isActiveFn = jest.fn().mockReturnValue(false); - - registry.register({ - pluginId, - configs: [ - { - title: 'Inactive Provider', - searchProvider: mockSearchProvider, - isActive: isActiveFn, - }, - ], - }); - - const context: PluginExtensionCommandPaletteContext = { - searchQuery: 'test', - }; - - await registry.search(context); - - expect(isActiveFn).toHaveBeenCalledWith(context); - expect(mockSearchProvider).not.toHaveBeenCalled(); - }); - - it('should execute search for active providers', async () => { - const registry = new CommandPaletteDynamicRegistry(); - const mockSearchProvider = jest.fn().mockResolvedValue([]); - const isActiveFn = jest.fn().mockReturnValue(true); - - registry.register({ - pluginId, - configs: [ - { - title: 'Active Provider', - searchProvider: mockSearchProvider, - isActive: isActiveFn, - }, - ], - }); - - const context: PluginExtensionCommandPaletteContext = { - searchQuery: 'test', - }; - - await registry.search(context); - - expect(isActiveFn).toHaveBeenCalledWith(context); - expect(mockSearchProvider).toHaveBeenCalled(); - }); - it('should limit results to 5 items per provider', async () => { const registry = new CommandPaletteDynamicRegistry(); const mockSearchProvider = jest.fn().mockResolvedValue([ @@ -506,7 +405,6 @@ describe('CommandPaletteDynamicRegistry', () => { pluginId, configs: [ { - title: 'Test Provider', searchProvider: mockSearchProvider, }, ], @@ -517,7 +415,7 @@ describe('CommandPaletteDynamicRegistry', () => { }; const results = await registry.search(context); - const searchResult = results.get(`${pluginId}/Test Provider`); + const searchResult = results.get(`${pluginId}/0`); expect(searchResult?.items).toHaveLength(5); expect(searchResult?.items[4].id).toBe('5'); @@ -547,7 +445,6 @@ describe('CommandPaletteDynamicRegistry', () => { pluginId, configs: [ { - title: 'Test Provider', searchProvider: mockSearchProvider, }, ], @@ -558,7 +455,7 @@ describe('CommandPaletteDynamicRegistry', () => { }; const results = await registry.search(context); - const searchResult = results.get(`${pluginId}/Test Provider`); + const searchResult = results.get(`${pluginId}/0`); expect(searchResult?.items).toHaveLength(1); expect(searchResult?.items[0].id).toBe('valid'); @@ -577,7 +474,6 @@ describe('CommandPaletteDynamicRegistry', () => { pluginId, configs: [ { - title: 'Test Provider', searchProvider: mockSearchProvider, }, ], @@ -588,7 +484,7 @@ describe('CommandPaletteDynamicRegistry', () => { }; const results = await registry.search(context); - const searchResult = results.get(`${pluginId}/Test Provider`); + const searchResult = results.get(`${pluginId}/0`); expect(searchResult?.items).toHaveLength(1); expect(searchResult?.items[0].id).toBe('valid'); @@ -607,7 +503,6 @@ describe('CommandPaletteDynamicRegistry', () => { pluginId, configs: [ { - title: 'Test Provider', searchProvider: mockSearchProvider, }, ], @@ -618,7 +513,7 @@ describe('CommandPaletteDynamicRegistry', () => { }; const results = await registry.search(context); - const searchResult = results.get(`${pluginId}/Test Provider`); + const searchResult = results.get(`${pluginId}/0`); expect(searchResult?.items).toHaveLength(1); expect(searchResult?.items[0].id).toBe('valid'); @@ -637,7 +532,6 @@ describe('CommandPaletteDynamicRegistry', () => { pluginId, configs: [ { - title: 'Test Provider', searchProvider: mockSearchProvider, }, ], @@ -648,7 +542,7 @@ describe('CommandPaletteDynamicRegistry', () => { }; const results = await registry.search(context); - const searchResult = results.get(`${pluginId}/Test Provider`); + const searchResult = results.get(`${pluginId}/0`); expect(searchResult?.items).toHaveLength(1); expect(searchResult?.items[0].id).toBe('valid'); @@ -666,7 +560,6 @@ describe('CommandPaletteDynamicRegistry', () => { pluginId, configs: [ { - title: 'Test Provider', searchProvider: mockSearchProvider, }, ], @@ -690,7 +583,6 @@ describe('CommandPaletteDynamicRegistry', () => { pluginId, configs: [ { - title: 'Test Provider', searchProvider: mockSearchProvider, }, ], @@ -726,7 +618,6 @@ describe('CommandPaletteDynamicRegistry', () => { pluginId, configs: [ { - title: 'Test Provider', searchProvider: mockSearchProvider, }, ], @@ -757,7 +648,6 @@ describe('CommandPaletteDynamicRegistry', () => { pluginId, configs: [ { - title: 'Test Provider', searchProvider: mockSearchProvider, }, ], @@ -781,7 +671,6 @@ describe('CommandPaletteDynamicRegistry', () => { pluginId: 'failing-plugin', configs: [ { - title: 'Failing Provider', searchProvider: failingProvider, }, ], @@ -791,7 +680,6 @@ describe('CommandPaletteDynamicRegistry', () => { pluginId: 'success-plugin', configs: [ { - title: 'Success Provider', searchProvider: successProvider, }, ], @@ -804,7 +692,7 @@ describe('CommandPaletteDynamicRegistry', () => { const results = await registry.search(context); expect(results.size).toBe(1); - expect(results.get('success-plugin/Success Provider')).toBeDefined(); + expect(results.get('success-plugin/0')).toBeDefined(); }); }); @@ -817,7 +705,6 @@ describe('CommandPaletteDynamicRegistry', () => { pluginId, configs: [ { - title: 'Test Provider', searchProvider: mockSearchProvider, minQueryLength: 0, }, @@ -841,7 +728,6 @@ describe('CommandPaletteDynamicRegistry', () => { pluginId, configs: [ { - title: 'Test Provider', searchProvider: mockSearchProvider, minQueryLength: 0, }, @@ -875,7 +761,6 @@ describe('CommandPaletteDynamicRegistry', () => { pluginId, configs: [ { - title: 'Test Provider', searchProvider: mockSearchProvider, }, ], @@ -886,7 +771,7 @@ describe('CommandPaletteDynamicRegistry', () => { }; const results = await registry.search(context); - const searchResult = results.get(`${pluginId}/Test Provider`); + const searchResult = results.get(`${pluginId}/0`); expect(searchResult?.items[0]).toEqual({ id: 'test-id', @@ -907,7 +792,6 @@ describe('CommandPaletteDynamicRegistry', () => { pluginId, configs: [ { - title: 'Test Provider', searchProvider: mockSearchProvider, category: 'Test Category', }, @@ -919,10 +803,9 @@ describe('CommandPaletteDynamicRegistry', () => { }; const results = await registry.search(context); - const searchResult = results.get(`${pluginId}/Test Provider`); + const searchResult = results.get(`${pluginId}/0`); expect(searchResult?.config.pluginId).toBe(pluginId); - expect(searchResult?.config.config.title).toBe('Test Provider'); expect(searchResult?.config.config.category).toBe('Test Category'); }); }); diff --git a/public/app/features/commandPalette/CommandPaletteDynamicRegistry.ts b/public/app/features/commandPalette/CommandPaletteDynamicRegistry.ts index b462a3bf3be..5ba6234d626 100644 --- a/public/app/features/commandPalette/CommandPaletteDynamicRegistry.ts +++ b/public/app/features/commandPalette/CommandPaletteDynamicRegistry.ts @@ -3,6 +3,7 @@ import { ReplaySubject, Subject, firstValueFrom, map, scan, startWith } from 'rx import { PluginExtensionCommandPaletteDynamicConfig, CommandPaletteDynamicResult, + DynamicPluginExtensionCommandPaletteContext, PluginExtensionCommandPaletteContext, } from '@grafana/data'; @@ -63,20 +64,17 @@ export class CommandPaletteDynamicRegistry { private mapToRegistry(registry: RegistryType, item: PluginExtensionConfigs): RegistryType { const { pluginId, configs } = item; - for (const config of configs) { - const { title, searchProvider, category } = config; - - if (!title || typeof title !== 'string') { - console.error(`${logPrefix} Plugin ${pluginId}: title is required and must be a string`); - continue; - } + for (let index = 0; index < configs.length; index++) { + const config = configs[index]; + const { searchProvider, category } = config; if (!searchProvider || typeof searchProvider !== 'function') { console.error(`${logPrefix} Plugin ${pluginId}: searchProvider must be a function`); continue; } - const providerId = `${pluginId}/${title}`; + // Use index to differentiate multiple providers from same plugin + const providerId = `${pluginId}/${index}`; if (!(providerId in registry)) { registry[providerId] = []; @@ -86,9 +84,8 @@ export class CommandPaletteDynamicRegistry { pluginId, config: { ...config, - category: category ?? pluginId, + category: category, minQueryLength: config.minQueryLength ?? 2, - debounceMs: config.debounceMs ?? 300, }, }); @@ -123,6 +120,13 @@ export class CommandPaletteDynamicRegistry { const registry = await this.getState(); const results = new Map(); const searchQuery = context.searchQuery ?? ''; + const signal = context.signal ?? new AbortController().signal; + + // Create the dynamic context with required fields for searchProvider + const dynamicContext: DynamicPluginExtensionCommandPaletteContext = { + searchQuery, + signal, + }; const searchPromises = Object.entries(registry).map(async ([providerId, registryItems]) => { if (!Array.isArray(registryItems) || registryItems.length === 0) { @@ -137,13 +141,8 @@ export class CommandPaletteDynamicRegistry { return; } - // Check if provider is active - if (config.isActive && !config.isActive(context)) { - return; - } - try { - const items = await config.searchProvider(context); + const items = await config.searchProvider(dynamicContext); // Validate results if (!Array.isArray(items)) { diff --git a/public/app/features/commandPalette/actions/useDynamicExtensionActions.ts b/public/app/features/commandPalette/actions/useDynamicExtensionActions.ts index bfcf910c7d9..f7f1cc2c06a 100644 --- a/public/app/features/commandPalette/actions/useDynamicExtensionActions.ts +++ b/public/app/features/commandPalette/actions/useDynamicExtensionActions.ts @@ -1,12 +1,7 @@ -import { ActionImpl } from 'kbar'; import { useEffect, useMemo, useRef, useState } from 'react'; import { useDebounce } from 'react-use'; -import { - CommandPaletteDynamicResult, - CommandPaletteDynamicResultAction, - PluginExtensionCommandPaletteContext, -} from '@grafana/data'; +import { CommandPaletteDynamicResult, PluginExtensionCommandPaletteContext } from '@grafana/data'; import { appEvents } from 'app/core/app_events'; import { CloseExtensionSidebarEvent, OpenExtensionSidebarEvent, ToggleExtensionSidebarEvent } from 'app/types/events'; @@ -15,26 +10,22 @@ import { commandPaletteDynamicRegistry, CommandPaletteDynamicSearchResult } from import { CommandPaletteAction } from '../types'; import { EXTENSIONS_PRIORITY } from '../values'; -interface DynamicResultWithAction extends CommandPaletteDynamicResult { +interface DynamicResultWithPluginId extends CommandPaletteDynamicResult { pluginId: string; - onSelect?: CommandPaletteDynamicResultAction; -} - -export interface DynamicExtensionResultGroup { - section: string; - items: ActionImpl[]; } /** * Fetches dynamic results from plugin extensions without registering them with kbar. * This allows the results to bypass kbar's fuzzy filtering since they're already * filtered by the plugin's searchProvider function. + * + * Returns flat CommandPaletteAction[] that can be concatenated with other search results. */ export function useDynamicExtensionResults(searchQuery: string): { - results: DynamicExtensionResultGroup[]; + results: CommandPaletteAction[]; isLoading: boolean; } { - const [dynamicResults, setDynamicResults] = useState([]); + const [dynamicResults, setDynamicResults] = useState([]); const [isLoading, setIsLoading] = useState(false); const abortControllerRef = useRef(null); @@ -81,14 +72,14 @@ export function useDynamicExtensionResults(searchQuery: string): { return; } - const allResults: DynamicResultWithAction[] = []; + const allResults: DynamicResultWithPluginId[] = []; resultsMap.forEach(({ items, config }: CommandPaletteDynamicSearchResult) => { items.forEach((item: CommandPaletteDynamicResult) => { allResults.push({ ...item, pluginId: config.pluginId, - onSelect: config.config.onSelect, + // Use item's section or fall back to config's category section: item.section ?? config.config.category, }); }); @@ -111,18 +102,12 @@ export function useDynamicExtensionResults(searchQuery: string): { }; }, [debouncedSearchQuery]); - // Group results by section and convert to ActionImpl objects - const results: DynamicExtensionResultGroup[] = useMemo(() => { - const groups = new Map(); - - dynamicResults.forEach((result) => { + // Convert dynamic results to CommandPaletteAction[] + const results: CommandPaletteAction[] = useMemo(() => { + return dynamicResults.map((result) => { const section = result.section ?? 'Dynamic Results'; - if (!groups.has(section)) { - groups.set(section, []); - } - - const action: CommandPaletteAction = { + return { id: `dynamic-${result.pluginId}-${result.id}`, name: result.title, section, @@ -133,7 +118,7 @@ export function useDynamicExtensionResults(searchQuery: string): { if (result.onSelect) { const extensionPointId = 'grafana/commandpalette/action'; result.onSelect(result, { - context: { searchQuery: debouncedSearchQuery }, + context: { searchQuery: debouncedSearchQuery, signal: new AbortController().signal }, extensionPointId, openModal: createOpenModalFunction({ pluginId: result.pluginId, @@ -169,15 +154,7 @@ export function useDynamicExtensionResults(searchQuery: string): { }, url: result.path, }; - - groups.get(section)!.push(action); }); - - // Convert to array of groups with ActionImpl objects - return Array.from(groups.entries()).map(([section, actions]) => ({ - section, - items: actions.map((action) => new ActionImpl(action, { store: {} })), - })); }, [dynamicResults, debouncedSearchQuery]); return { results, isLoading };