feat: dynamic command palette search results
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
PluginExtensionAddedComponentConfig,
|
||||
PluginExtensionAddedLinkConfig,
|
||||
PluginExtensionAddedFunctionConfig,
|
||||
PluginExtensionCommandPaletteDynamicConfig,
|
||||
} from './pluginExtensions';
|
||||
|
||||
/**
|
||||
@@ -62,6 +63,7 @@ export class AppPlugin<T extends KeyValue = KeyValue> extends GrafanaPlugin<AppP
|
||||
private _addedComponentConfigs: PluginExtensionAddedComponentConfig[] = [];
|
||||
private _addedLinkConfigs: PluginExtensionAddedLinkConfig[] = [];
|
||||
private _addedFunctionConfigs: PluginExtensionAddedFunctionConfig[] = [];
|
||||
private _commandPaletteDynamicConfigs: PluginExtensionCommandPaletteDynamicConfig[] = [];
|
||||
|
||||
// Content under: /a/${plugin-id}/*
|
||||
root?: ComponentType<AppRootProps<T>>;
|
||||
@@ -117,6 +119,10 @@ export class AppPlugin<T extends KeyValue = KeyValue> extends GrafanaPlugin<AppP
|
||||
return this._addedFunctionConfigs;
|
||||
}
|
||||
|
||||
get commandPaletteDynamicConfigs() {
|
||||
return this._commandPaletteDynamicConfigs;
|
||||
}
|
||||
|
||||
addLink<Context extends object>(linkConfig: PluginExtensionAddedLinkConfig<Context>) {
|
||||
this._addedLinkConfigs.push(linkConfig as PluginExtensionAddedLinkConfig);
|
||||
|
||||
@@ -140,6 +146,40 @@ export class AppPlugin<T extends KeyValue = KeyValue> extends GrafanaPlugin<AppP
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a dynamic command palette search provider
|
||||
*
|
||||
* Allows plugins to add dynamic, search-based results to the command palette.
|
||||
* Results are fetched asynchronously based on user input.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* plugin.addCommandPaletteDynamicProvider({
|
||||
* title: 'Search Issues',
|
||||
* category: 'My Plugin',
|
||||
* searchProvider: async ({ searchQuery, signal }) => {
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Action handler for when a dynamic result is selected
|
||||
*/
|
||||
export type CommandPaletteDynamicResultAction = (
|
||||
result: CommandPaletteDynamicResult,
|
||||
helpers: PluginExtensionEventHelpers<PluginExtensionCommandPaletteContext>
|
||||
) => void | Promise<void>;
|
||||
|
||||
/**
|
||||
* Search provider function that fetches dynamic results
|
||||
*/
|
||||
export type CommandPaletteDynamicSearchProvider = (
|
||||
context: PluginExtensionCommandPaletteContext
|
||||
) => Promise<CommandPaletteDynamicResult[]>;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
<div className={styles.loadingBarContainer}>
|
||||
{isFetchingSearchResults && <LoadingBar width={500} delay={0} />}
|
||||
{(isFetchingSearchResults || isDynamicLoading) && <LoadingBar width={500} delay={0} />}
|
||||
</div>
|
||||
</div>
|
||||
{scopesRow ? <div className={styles.searchContainer}>{scopesRow}</div> : null}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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<DynamicResultWithAction[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const abortControllerRef = useRef<AbortController | null>(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 };
|
||||
}
|
||||
|
||||
@@ -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<RegistryType<CommandPaletteDynamicRegistryItem[]>>;
|
||||
initialState?: RegistryType<CommandPaletteDynamicRegistryItem[]>;
|
||||
} = {}
|
||||
) {
|
||||
super(options);
|
||||
}
|
||||
|
||||
mapToRegistry(
|
||||
registry: RegistryType<CommandPaletteDynamicRegistryItem[]>,
|
||||
item: PluginExtensionConfigs<PluginExtensionCommandPaletteDynamicConfig>
|
||||
): RegistryType<CommandPaletteDynamicRegistryItem[]> {
|
||||
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<Map<string, CommandPaletteDynamicSearchResult>> {
|
||||
const registry = await this.getState();
|
||||
const results = new Map<string, CommandPaletteDynamicSearchResult>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<AppPlugin, AppPluginMeta> = 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;
|
||||
|
||||
Reference in New Issue
Block a user