From 71b0482b1f4a4e09a769a00500628547f12a3d0b Mon Sep 17 00:00:00 2001 From: kozhuhds Date: Thu, 30 Oct 2025 11:05:25 +0100 Subject: [PATCH] feat: dynamic command palette search results --- packages/grafana-data/src/index.ts | 4 + packages/grafana-data/src/types/app.ts | 40 +++++ .../src/types/pluginExtensions.ts | 88 ++++++++++- .../commandPalette/CommandPalette.tsx | 7 +- .../commandPalette/actions/useActions.tsx | 13 ++ .../actions/useDynamicExtensionActions.ts | 130 +++++++++++++++ .../registry/CommandPaletteDynamicRegistry.ts | 149 ++++++++++++++++++ .../plugins/extensions/registry/setup.ts | 2 + .../plugins/importer/pluginImporter.ts | 2 + 9 files changed, 432 insertions(+), 3 deletions(-) create mode 100644 public/app/features/commandPalette/actions/useDynamicExtensionActions.ts create mode 100644 public/app/features/plugins/extensions/registry/CommandPaletteDynamicRegistry.ts diff --git a/packages/grafana-data/src/index.ts b/packages/grafana-data/src/index.ts index e058612a3c7..cfc550b8888 100644 --- a/packages/grafana-data/src/index.ts +++ b/packages/grafana-data/src/index.ts @@ -596,6 +596,10 @@ export { type PluginExtensionAddedFunctionConfig, type PluginExtensionResourceAttributesContext, type CentralAlertHistorySceneV1Props, + type PluginExtensionCommandPaletteDynamicConfig, + type CommandPaletteDynamicResult, + type CommandPaletteDynamicSearchProvider, + type CommandPaletteDynamicResultAction, } from './types/pluginExtensions'; export { type ScopeDashboardBindingSpec, diff --git a/packages/grafana-data/src/types/app.ts b/packages/grafana-data/src/types/app.ts index a3171ef76dd..5fc5a8a66b3 100644 --- a/packages/grafana-data/src/types/app.ts +++ b/packages/grafana-data/src/types/app.ts @@ -10,6 +10,7 @@ import { PluginExtensionAddedComponentConfig, PluginExtensionAddedLinkConfig, PluginExtensionAddedFunctionConfig, + PluginExtensionCommandPaletteDynamicConfig, } from './pluginExtensions'; /** @@ -62,6 +63,7 @@ export class AppPlugin extends GrafanaPlugin>; @@ -117,6 +119,10 @@ export class AppPlugin extends GrafanaPlugin(linkConfig: PluginExtensionAddedLinkConfig) { this._addedLinkConfigs.push(linkConfig as PluginExtensionAddedLinkConfig); @@ -140,6 +146,40 @@ export class AppPlugin extends GrafanaPlugin { + * const response = await fetch(`/api/issues?q=${searchQuery}`, { signal }); + * const issues = await response.json(); + * return issues.slice(0, 5).map(issue => ({ + * id: issue.id, + * title: issue.title, + * description: `#${issue.number}`, + * })); + * }, + * onSelect: (result, helpers) => { + * helpers.openModal({ + * title: result.title, + * body: IssueDetailsModal, + * }); + * }, + * }); + * ``` + */ + addCommandPaletteDynamicProvider(config: PluginExtensionCommandPaletteDynamicConfig) { + this._commandPaletteDynamicConfigs.push(config); + return this; + } } /** diff --git a/packages/grafana-data/src/types/pluginExtensions.ts b/packages/grafana-data/src/types/pluginExtensions.ts index 4b988c49a0a..f84e809e414 100644 --- a/packages/grafana-data/src/types/pluginExtensions.ts +++ b/packages/grafana-data/src/types/pluginExtensions.ts @@ -280,7 +280,12 @@ export type PluginExtensionDataSourceConfigContext< setSecureJsonData: (secureJsonData: SecureJsonData) => void; }; -export type PluginExtensionCommandPaletteContext = {}; +export type PluginExtensionCommandPaletteContext = { + /** 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 @@ -335,3 +340,84 @@ type Dashboard = { title: string; tags: string[]; }; + +// Dynamic Command Palette Types +// -------------------------------------------------------- + +/** + * A single dynamic result item returned by a command palette search provider + */ +export type CommandPaletteDynamicResult = { + /** Unique identifier for this result (scoped to plugin) */ + id: string; + /** Display title */ + title: string; + /** Optional subtitle or description */ + description?: string; + /** Optional URL to navigate to (alternative to onSelect) */ + path?: string; + /** Optional keywords for better search matching */ + keywords?: string[]; + /** Optional section/category override (defaults to plugin category) */ + section?: string; + /** Optional custom data to pass through to the action handler */ + data?: Record; +}; + +/** + * Action handler for when a dynamic result is selected + */ +export type CommandPaletteDynamicResultAction = ( + result: CommandPaletteDynamicResult, + helpers: PluginExtensionEventHelpers +) => void | Promise; + +/** + * Search provider function that fetches dynamic results + */ +export type CommandPaletteDynamicSearchProvider = ( + context: PluginExtensionCommandPaletteContext +) => 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?: string; + + /** + * Minimum query length before search is triggered + * @default 2 + */ + minQueryLength?: number; + + /** + * Debounce delay in milliseconds + * @default 300 + */ + debounceMs?: number; + + /** + * Search provider function that returns results + */ + 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 1bd1634546c..d6ba2ff7cd1 100644 --- a/public/app/features/commandPalette/CommandPalette.tsx +++ b/public/app/features/commandPalette/CommandPalette.tsx @@ -16,7 +16,7 @@ import { KBarSearch } from './KBarSearch'; import { ResultItem } from './ResultItem'; import { useSearchResults } from './actions/dashboardActions'; import { useRegisterRecentScopesActions, useRegisterScopesActions } from './actions/scopeActions'; -import { useRegisterRecentDashboardsActions, useRegisterStaticActions } from './actions/useActions'; +import { useRegisterDynamicActions, useRegisterRecentDashboardsActions, useRegisterStaticActions } from './actions/useActions'; import { CommandPaletteAction } from './types'; import { useMatches } from './useMatches'; @@ -47,6 +47,9 @@ function CommandPaletteContents() { useRegisterRecentDashboardsActions(); useRegisterRecentScopesActions(); + // Register dynamic actions from plugins based on search query + const { isLoading: isDynamicLoading } = useRegisterDynamicActions(searchQuery); + const queryToggle = useCallback(() => query.toggle(), [query]); const { scopesRow } = useRegisterScopesActions(searchQuery, queryToggle, currentRootActionId); @@ -83,7 +86,7 @@ function CommandPaletteContents() { className={styles.search} />
- {isFetchingSearchResults && } + {(isFetchingSearchResults || isDynamicLoading) && }
{scopesRow ?
{scopesRow}
: null} diff --git a/public/app/features/commandPalette/actions/useActions.tsx b/public/app/features/commandPalette/actions/useActions.tsx index 8b74bcb5b52..7276c6ac2d8 100644 --- a/public/app/features/commandPalette/actions/useActions.tsx +++ b/public/app/features/commandPalette/actions/useActions.tsx @@ -5,6 +5,7 @@ import { CommandPaletteAction } from '../types'; import { getRecentDashboardActions } from './dashboardActions'; import { useStaticActions } from './staticActions'; +import { useDynamicExtensionActions } from './useDynamicExtensionActions'; import useExtensionActions from './useExtensionActions'; /** @@ -33,3 +34,15 @@ export function useRegisterRecentDashboardsActions() { useRegisterActions(recentDashboardActions, [recentDashboardActions]); } + +/** + * Register dynamic actions based on search query + * These are fetched from plugins that registered dynamic search providers + */ +export function useRegisterDynamicActions(searchQuery: string) { + const { actions: dynamicActions, isLoading } = useDynamicExtensionActions(searchQuery); + + useRegisterActions(dynamicActions, [dynamicActions]); + + return { isLoading }; +} diff --git a/public/app/features/commandPalette/actions/useDynamicExtensionActions.ts b/public/app/features/commandPalette/actions/useDynamicExtensionActions.ts new file mode 100644 index 00000000000..ab3a4a24c65 --- /dev/null +++ b/public/app/features/commandPalette/actions/useDynamicExtensionActions.ts @@ -0,0 +1,130 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { useDebounce } from 'react-use'; + +import { + CommandPaletteDynamicResult, + CommandPaletteDynamicResultAction, + PluginExtensionCommandPaletteContext, +} from '@grafana/data'; + +import { commandPaletteDynamicRegistry } from '../../plugins/extensions/registry/setup'; +import { CommandPaletteAction } from '../types'; +import { EXTENSIONS_PRIORITY } from '../values'; + +interface DynamicResultWithAction extends CommandPaletteDynamicResult { + pluginId: string; + onSelect?: CommandPaletteDynamicResultAction; +} + +export function useDynamicExtensionActions(searchQuery: string): { + actions: CommandPaletteAction[]; + isLoading: boolean; +} { + const [dynamicResults, setDynamicResults] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const abortControllerRef = useRef(null); + + // Debounce the search query + const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(searchQuery); + + useDebounce( + () => { + setDebouncedSearchQuery(searchQuery); + }, + 300, + [searchQuery] + ); + + useEffect(() => { + // Cancel previous request + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + + // Clear results if query is too short + if (debouncedSearchQuery.length < 2) { + setDynamicResults([]); + setIsLoading(false); + return; + } + + // Create new abort controller + const abortController = new AbortController(); + abortControllerRef.current = abortController; + + setIsLoading(true); + + const context: PluginExtensionCommandPaletteContext = { + searchQuery: debouncedSearchQuery, + signal: abortController.signal, + }; + + // Execute search across all registered providers + commandPaletteDynamicRegistry + .search(context) + .then((resultsMap) => { + if (abortController.signal.aborted) { + return; + } + + const allResults: DynamicResultWithAction[] = []; + + resultsMap.forEach(({ items, config }) => { + items.forEach((item) => { + allResults.push({ + ...item, + pluginId: config.pluginId, + onSelect: config.config.onSelect, + section: item.section ?? config.config.category, + }); + }); + }); + + setDynamicResults(allResults); + setIsLoading(false); + }) + .catch((error) => { + if (!abortController.signal.aborted) { + console.error('[CommandPalette] Dynamic search failed:', error); + setDynamicResults([]); + setIsLoading(false); + } + }); + + // Cleanup + return () => { + abortController.abort(); + }; + }, [debouncedSearchQuery]); + + const actions: CommandPaletteAction[] = useMemo(() => { + return dynamicResults.map((result) => ({ + id: `dynamic-${result.pluginId}-${result.id}`, + name: result.title, + section: result.section ?? 'Dynamic Results', + subtitle: result.description, + priority: EXTENSIONS_PRIORITY - 0.5, // Slightly lower than static extensions + keywords: result.keywords?.join(' '), + perform: () => { + if (result.onSelect) { + result.onSelect(result, { + context: { searchQuery: debouncedSearchQuery }, + extensionPointId: 'grafana/commandpalette/action', + openModal: () => { + console.warn('openModal: Full implementation requires createOpenModalFunction from extensions/utils'); + }, + openSidebar: () => { + console.warn('openSidebar: Available but marked as internal API'); + }, + closeSidebar: () => {}, + toggleSidebar: () => {}, + }); + } + }, + url: result.path, + })); + }, [dynamicResults, debouncedSearchQuery]); + + return { actions, isLoading }; +} + diff --git a/public/app/features/plugins/extensions/registry/CommandPaletteDynamicRegistry.ts b/public/app/features/plugins/extensions/registry/CommandPaletteDynamicRegistry.ts new file mode 100644 index 00000000000..1f2e4cfee87 --- /dev/null +++ b/public/app/features/plugins/extensions/registry/CommandPaletteDynamicRegistry.ts @@ -0,0 +1,149 @@ +import { ReplaySubject } from 'rxjs'; + +import { + PluginExtensionCommandPaletteDynamicConfig, + CommandPaletteDynamicResult, + PluginExtensionCommandPaletteContext, +} from '@grafana/data'; + +import { isGrafanaDevMode } from '../utils'; + +import { PluginExtensionConfigs, Registry, RegistryType } from './Registry'; + +const logPrefix = '[CommandPaletteDynamic]'; + +export interface CommandPaletteDynamicRegistryItem { + pluginId: string; + config: PluginExtensionCommandPaletteDynamicConfig; +} + +export interface CommandPaletteDynamicSearchResult { + items: CommandPaletteDynamicResult[]; + config: CommandPaletteDynamicRegistryItem; +} + +export class CommandPaletteDynamicRegistry extends Registry< + CommandPaletteDynamicRegistryItem[], + PluginExtensionCommandPaletteDynamicConfig +> { + constructor( + options: { + registrySubject?: ReplaySubject>; + initialState?: RegistryType; + } = {} + ) { + super(options); + } + + 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') { + this.logger.error(`${logPrefix} Plugin ${pluginId}: title is required and must be a string`); + continue; + } + + if (!searchProvider || typeof searchProvider !== 'function') { + this.logger.error(`${logPrefix} Plugin ${pluginId}: searchProvider must be a function`); + continue; + } + + const providerId = `${pluginId}/${title}`; + + if (!(providerId in registry)) { + registry[providerId] = []; + } + + registry[providerId].push({ + pluginId, + config: { + ...config, + category: category ?? pluginId, + minQueryLength: config.minQueryLength ?? 2, + debounceMs: config.debounceMs ?? 300, + }, + }); + + if (isGrafanaDevMode()) { + console.log(`${logPrefix} Registered provider: ${providerId}`); + } + } + + return registry; + } + + /** + * Execute a search across all registered providers + */ + async search( + context: PluginExtensionCommandPaletteContext + ): Promise> { + const registry = await this.getState(); + const results = new Map(); + const searchQuery = context.searchQuery ?? ''; + + const searchPromises = Object.entries(registry).map(async ([providerId, registryItems]) => { + if (!Array.isArray(registryItems) || registryItems.length === 0) { + return; + } + + const item = registryItems[0]; // Take first config per provider + const { config } = item; + + // Check minimum query length + if (searchQuery.length < (config.minQueryLength ?? 2)) { + return; + } + + // Check if provider is active + if (config.isActive && !config.isActive(context)) { + return; + } + + try { + const items = await config.searchProvider(context); + + // Validate results + if (!Array.isArray(items)) { + this.logger.warning(`${logPrefix} Provider ${providerId} did not return an array`); + return; + } + + // Validate and filter items + const validItems = items + .filter((item) => { + if (!item.id || typeof item.id !== 'string') { + this.logger.warning(`${logPrefix} Provider ${providerId}: result missing id`); + return false; + } + if (!item.title || typeof item.title !== 'string') { + this.logger.warning(`${logPrefix} Provider ${providerId}: result missing title`); + return false; + } + return true; + }) + .slice(0, 5); // Limit to 5 items maximum + + if (validItems.length > 0) { + results.set(providerId, { items: validItems, config: item }); + } + } catch (error) { + // Don't log AbortErrors as they are expected + if (error instanceof Error && error.name === 'AbortError') { + return; + } + this.logger.error(`${logPrefix} Search failed for ${providerId}`, { error: String(error) }); + } + }); + + await Promise.all(searchPromises); + return results; + } +} + diff --git a/public/app/features/plugins/extensions/registry/setup.ts b/public/app/features/plugins/extensions/registry/setup.ts index bb25c487dc3..53b9af18b20 100644 --- a/public/app/features/plugins/extensions/registry/setup.ts +++ b/public/app/features/plugins/extensions/registry/setup.ts @@ -7,6 +7,7 @@ import { getCoreExtensionConfigurations } from '../getCoreExtensionConfiguration import { AddedComponentsRegistry } from './AddedComponentsRegistry'; import { AddedFunctionsRegistry } from './AddedFunctionsRegistry'; import { AddedLinksRegistry } from './AddedLinksRegistry'; +import { CommandPaletteDynamicRegistry } from './CommandPaletteDynamicRegistry'; import { ExposedComponentsRegistry } from './ExposedComponentsRegistry'; import { PluginExtensionRegistries } from './types'; @@ -14,6 +15,7 @@ export const addedComponentsRegistry = new AddedComponentsRegistry(); export const exposedComponentsRegistry = new ExposedComponentsRegistry(); export const addedLinksRegistry = new AddedLinksRegistry(); export const addedFunctionsRegistry = new AddedFunctionsRegistry(); +export const commandPaletteDynamicRegistry = new CommandPaletteDynamicRegistry(); export const pluginExtensionRegistries: PluginExtensionRegistries = { addedComponentsRegistry, exposedComponentsRegistry, diff --git a/public/app/features/plugins/importer/pluginImporter.ts b/public/app/features/plugins/importer/pluginImporter.ts index cf2b407021b..0610b707f3e 100644 --- a/public/app/features/plugins/importer/pluginImporter.ts +++ b/public/app/features/plugins/importer/pluginImporter.ts @@ -20,6 +20,7 @@ import { addedComponentsRegistry, addedFunctionsRegistry, addedLinksRegistry, + commandPaletteDynamicRegistry, exposedComponentsRegistry, } from '../extensions/registry/setup'; import { pluginsLogger } from '../utils'; @@ -102,6 +103,7 @@ const appPluginPostImport: PostImportStrategy = async addedComponentsRegistry.register({ pluginId: meta.id, configs: plugin.addedComponentConfigs || [] }); addedLinksRegistry.register({ pluginId: meta.id, configs: plugin.addedLinkConfigs || [] }); addedFunctionsRegistry.register({ pluginId: meta.id, configs: plugin.addedFunctionConfigs || [] }); + commandPaletteDynamicRegistry.register({ pluginId: meta.id, configs: plugin.commandPaletteDynamicConfigs || [] }); pluginsCache.set(meta.id, plugin); return plugin;