refactor: addressing pr comments
This commit is contained in:
@@ -590,6 +590,7 @@ export {
|
||||
type PluginExtensionDataSourceConfigActionsContext,
|
||||
type PluginExtensionDataSourceConfigStatusContext,
|
||||
type PluginExtensionCommandPaletteContext,
|
||||
type DynamicPluginExtensionCommandPaletteContext,
|
||||
type PluginExtensionOpenModalOptions,
|
||||
type PluginExtensionExposedComponentConfig,
|
||||
type PluginExtensionAddedComponentConfig,
|
||||
|
||||
@@ -156,7 +156,6 @@ export class AppPlugin<T extends KeyValue = KeyValue> extends GrafanaPlugin<AppP
|
||||
* @example
|
||||
* ```typescript
|
||||
* plugin.addCommandPaletteDynamicProvider({
|
||||
* title: 'Search Issues',
|
||||
* category: 'My Plugin',
|
||||
* searchProvider: async ({ searchQuery, signal }) => {
|
||||
* const response = await fetch(`/api/issues?q=${searchQuery}`, { signal });
|
||||
@@ -165,14 +164,15 @@ export class AppPlugin<T extends KeyValue = KeyValue> extends GrafanaPlugin<AppP
|
||||
* id: issue.id,
|
||||
* title: issue.title,
|
||||
* description: `#${issue.number}`,
|
||||
* // onSelect is per-result, allowing mixed navigation and custom actions
|
||||
* onSelect: (result, helpers) => {
|
||||
* helpers.openModal({
|
||||
* title: result.title,
|
||||
* body: IssueDetailsModal,
|
||||
* });
|
||||
* },
|
||||
* }));
|
||||
* },
|
||||
* onSelect: (result, helpers) => {
|
||||
* helpers.openModal({
|
||||
* title: result.title,
|
||||
* body: IssueDetailsModal,
|
||||
* });
|
||||
* },
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
|
||||
@@ -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<string, string[]>;
|
||||
@@ -367,33 +378,35 @@ export type CommandPaletteDynamicResult = {
|
||||
section?: string;
|
||||
/** Optional custom data to pass through to the action handler */
|
||||
data?: Record<string, unknown>;
|
||||
/**
|
||||
* 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<PluginExtensionCommandPaletteContext>
|
||||
result: Omit<CommandPaletteDynamicResult, 'onSelect'>,
|
||||
helpers: PluginExtensionEventHelpers<DynamicPluginExtensionCommandPaletteContext>
|
||||
) => void | Promise<void>;
|
||||
|
||||
/**
|
||||
* Search provider function that fetches dynamic results
|
||||
*/
|
||||
export type CommandPaletteDynamicSearchProvider = (
|
||||
context: PluginExtensionCommandPaletteContext
|
||||
context: DynamicPluginExtensionCommandPaletteContext
|
||||
) => 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/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;
|
||||
};
|
||||
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
const { overlayProps } = useOverlay(
|
||||
@@ -95,9 +104,8 @@ function CommandPaletteContents() {
|
||||
{scopesRow ? <div className={styles.searchContainer}>{scopesRow}</div> : null}
|
||||
<div className={styles.resultsContainer}>
|
||||
<RenderResults
|
||||
isFetchingSearchResults={isFetchingSearchResults}
|
||||
isFetchingSearchResults={isFetchingSearchResults || isDynamicLoading}
|
||||
searchResults={searchResults}
|
||||
dynamicResults={dynamicResults}
|
||||
searchQuery={searchQuery}
|
||||
/>
|
||||
</div>
|
||||
@@ -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<string, ActionImpl[]>();
|
||||
|
||||
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(() => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, CommandPaletteDynamicSearchResult>();
|
||||
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)) {
|
||||
|
||||
@@ -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<DynamicResultWithAction[]>([]);
|
||||
const [dynamicResults, setDynamicResults] = useState<DynamicResultWithPluginId[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const abortControllerRef = useRef<AbortController | null>(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<string, CommandPaletteAction[]>();
|
||||
|
||||
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 };
|
||||
|
||||
Reference in New Issue
Block a user