mirror of
https://github.com/grafana/grafana.git
synced 2025-12-24 05:44:14 +08:00
Compare commits
16 Commits
zoltan/pos
...
kozhuhds/d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0042b990e | ||
|
|
4ac7e78030 | ||
|
|
104b26bcef | ||
|
|
9c20cc2816 | ||
|
|
b5a2dd5b23 | ||
|
|
0bc022f5ee | ||
|
|
1c4e0260b5 | ||
|
|
88d4aaba99 | ||
|
|
688c51a306 | ||
|
|
f987a33870 | ||
|
|
e89c83028f | ||
|
|
5d5bca4f31 | ||
|
|
7a8e4b89bf | ||
|
|
7a1d7463c3 | ||
|
|
8ee1fb55e7 | ||
|
|
71b0482b1f |
@@ -590,6 +590,7 @@ export {
|
|||||||
type PluginExtensionDataSourceConfigActionsContext,
|
type PluginExtensionDataSourceConfigActionsContext,
|
||||||
type PluginExtensionDataSourceConfigStatusContext,
|
type PluginExtensionDataSourceConfigStatusContext,
|
||||||
type PluginExtensionCommandPaletteContext,
|
type PluginExtensionCommandPaletteContext,
|
||||||
|
type DynamicPluginExtensionCommandPaletteContext,
|
||||||
type PluginExtensionOpenModalOptions,
|
type PluginExtensionOpenModalOptions,
|
||||||
type PluginExtensionExposedComponentConfig,
|
type PluginExtensionExposedComponentConfig,
|
||||||
type PluginExtensionAddedComponentConfig,
|
type PluginExtensionAddedComponentConfig,
|
||||||
@@ -597,6 +598,10 @@ export {
|
|||||||
type PluginExtensionAddedFunctionConfig,
|
type PluginExtensionAddedFunctionConfig,
|
||||||
type PluginExtensionResourceAttributesContext,
|
type PluginExtensionResourceAttributesContext,
|
||||||
type CentralAlertHistorySceneV1Props,
|
type CentralAlertHistorySceneV1Props,
|
||||||
|
type PluginExtensionCommandPaletteDynamicConfig,
|
||||||
|
type CommandPaletteDynamicResult,
|
||||||
|
type CommandPaletteDynamicSearchProvider,
|
||||||
|
type CommandPaletteDynamicResultAction,
|
||||||
} from './types/pluginExtensions';
|
} from './types/pluginExtensions';
|
||||||
export {
|
export {
|
||||||
type ScopeDashboardBindingSpec,
|
type ScopeDashboardBindingSpec,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
PluginExtensionAddedComponentConfig,
|
PluginExtensionAddedComponentConfig,
|
||||||
PluginExtensionAddedLinkConfig,
|
PluginExtensionAddedLinkConfig,
|
||||||
PluginExtensionAddedFunctionConfig,
|
PluginExtensionAddedFunctionConfig,
|
||||||
|
PluginExtensionCommandPaletteDynamicConfig,
|
||||||
} from './pluginExtensions';
|
} from './pluginExtensions';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -62,6 +63,7 @@ export class AppPlugin<T extends KeyValue = KeyValue> extends GrafanaPlugin<AppP
|
|||||||
private _addedComponentConfigs: PluginExtensionAddedComponentConfig[] = [];
|
private _addedComponentConfigs: PluginExtensionAddedComponentConfig[] = [];
|
||||||
private _addedLinkConfigs: PluginExtensionAddedLinkConfig[] = [];
|
private _addedLinkConfigs: PluginExtensionAddedLinkConfig[] = [];
|
||||||
private _addedFunctionConfigs: PluginExtensionAddedFunctionConfig[] = [];
|
private _addedFunctionConfigs: PluginExtensionAddedFunctionConfig[] = [];
|
||||||
|
private _commandPaletteDynamicConfigs: PluginExtensionCommandPaletteDynamicConfig[] = [];
|
||||||
|
|
||||||
// Content under: /a/${plugin-id}/*
|
// Content under: /a/${plugin-id}/*
|
||||||
root?: ComponentType<AppRootProps<T>>;
|
root?: ComponentType<AppRootProps<T>>;
|
||||||
@@ -117,6 +119,10 @@ export class AppPlugin<T extends KeyValue = KeyValue> extends GrafanaPlugin<AppP
|
|||||||
return this._addedFunctionConfigs;
|
return this._addedFunctionConfigs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get commandPaletteDynamicConfigs() {
|
||||||
|
return this._commandPaletteDynamicConfigs;
|
||||||
|
}
|
||||||
|
|
||||||
addLink<Context extends object>(linkConfig: PluginExtensionAddedLinkConfig<Context>) {
|
addLink<Context extends object>(linkConfig: PluginExtensionAddedLinkConfig<Context>) {
|
||||||
this._addedLinkConfigs.push(linkConfig as PluginExtensionAddedLinkConfig);
|
this._addedLinkConfigs.push(linkConfig as PluginExtensionAddedLinkConfig);
|
||||||
|
|
||||||
@@ -140,6 +146,40 @@ export class AppPlugin<T extends KeyValue = KeyValue> extends GrafanaPlugin<AppP
|
|||||||
|
|
||||||
return this;
|
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({
|
||||||
|
* 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 is per-result, allowing mixed navigation and custom actions
|
||||||
|
* onSelect: (result, helpers) => {
|
||||||
|
* helpers.openModal({
|
||||||
|
* title: result.title,
|
||||||
|
* body: IssueDetailsModal,
|
||||||
|
* });
|
||||||
|
* },
|
||||||
|
* }));
|
||||||
|
* },
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
addCommandPaletteDynamicProvider(config: PluginExtensionCommandPaletteDynamicConfig) {
|
||||||
|
this._commandPaletteDynamicConfigs.push(config);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -285,7 +285,23 @@ export type PluginExtensionDataSourceConfigContext<
|
|||||||
setSecureJsonData: (secureJsonData: SecureJsonData) => void;
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 = {
|
export type PluginExtensionResourceAttributesContext = {
|
||||||
// Key-value pairs of resource attributes, attribute name is the key
|
// Key-value pairs of resource attributes, attribute name is the key
|
||||||
@@ -340,3 +356,69 @@ type Dashboard = {
|
|||||||
title: string;
|
title: string;
|
||||||
tags: 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 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: Omit<CommandPaletteDynamicResult, 'onSelect'>,
|
||||||
|
helpers: PluginExtensionEventHelpers<DynamicPluginExtensionCommandPaletteContext>
|
||||||
|
) => void | Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search provider function that fetches dynamic results
|
||||||
|
*/
|
||||||
|
export type CommandPaletteDynamicSearchProvider = (
|
||||||
|
context: DynamicPluginExtensionCommandPaletteContext
|
||||||
|
) => Promise<CommandPaletteDynamicResult[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for registering a dynamic command palette provider
|
||||||
|
*/
|
||||||
|
export type PluginExtensionCommandPaletteDynamicConfig = {
|
||||||
|
/**
|
||||||
|
* Category/section name for grouping results.
|
||||||
|
* If not provided, results will be grouped under "Dynamic Results".
|
||||||
|
*/
|
||||||
|
category?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimum query length before search is triggered
|
||||||
|
* @default 2
|
||||||
|
*/
|
||||||
|
minQueryLength?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search provider function that returns results.
|
||||||
|
* Return an empty array to skip results for the current search.
|
||||||
|
*/
|
||||||
|
searchProvider: CommandPaletteDynamicSearchProvider;
|
||||||
|
};
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { ResultItem } from './ResultItem';
|
|||||||
import { useSearchResults } from './actions/dashboardActions';
|
import { useSearchResults } from './actions/dashboardActions';
|
||||||
import { useRegisterRecentScopesActions, useRegisterScopesActions } from './actions/scopeActions';
|
import { useRegisterRecentScopesActions, useRegisterScopesActions } from './actions/scopeActions';
|
||||||
import { useRegisterRecentDashboardsActions, useRegisterStaticActions } from './actions/useActions';
|
import { useRegisterRecentDashboardsActions, useRegisterStaticActions } from './actions/useActions';
|
||||||
|
import { useDynamicExtensionResults } from './actions/useDynamicExtensionActions';
|
||||||
import { CommandPaletteAction } from './types';
|
import { CommandPaletteAction } from './types';
|
||||||
import { useMatches } from './useMatches';
|
import { useMatches } from './useMatches';
|
||||||
|
|
||||||
@@ -51,12 +52,25 @@ function CommandPaletteContents() {
|
|||||||
const queryToggle = useCallback(() => query.toggle(), [query]);
|
const queryToggle = useCallback(() => query.toggle(), [query]);
|
||||||
const { scopesRow } = useRegisterScopesActions(searchQuery, queryToggle, currentRootActionId);
|
const { scopesRow } = useRegisterScopesActions(searchQuery, queryToggle, currentRootActionId);
|
||||||
|
|
||||||
|
// Fetch dynamic results from plugins - these bypass kbar's fuzzy filtering
|
||||||
|
// since they're already filtered by the plugin's searchProvider
|
||||||
|
const { results: dynamicResults, isLoading: isDynamicLoading } = useDynamicExtensionResults(searchQuery);
|
||||||
|
|
||||||
// This searches dashboards and folders it shows only if we are not in some specific category (and there is no
|
// This searches dashboards and folders it shows only if we are not in some specific category (and there is no
|
||||||
// dashboards category right now, so if any category is selected, we don't show these).
|
// dashboards category right now, so if any category is selected, we don't show these).
|
||||||
// Normally we register actions with kbar, and it knows not to show actions which are under a different parent than is
|
// 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
|
// the currentRootActionId. Because these search results are manually added to the list later, they would show every
|
||||||
// time.
|
// 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 ref = useRef<HTMLDivElement>(null);
|
||||||
const { overlayProps } = useOverlay(
|
const { overlayProps } = useOverlay(
|
||||||
@@ -84,13 +98,13 @@ function CommandPaletteContents() {
|
|||||||
className={styles.search}
|
className={styles.search}
|
||||||
/>
|
/>
|
||||||
<div className={styles.loadingBarContainer}>
|
<div className={styles.loadingBarContainer}>
|
||||||
{isFetchingSearchResults && <LoadingBar width={500} delay={0} />}
|
{(isFetchingSearchResults || isDynamicLoading) && <LoadingBar width={500} delay={0} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{scopesRow ? <div className={styles.searchContainer}>{scopesRow}</div> : null}
|
{scopesRow ? <div className={styles.searchContainer}>{scopesRow}</div> : null}
|
||||||
<div className={styles.resultsContainer}>
|
<div className={styles.resultsContainer}>
|
||||||
<RenderResults
|
<RenderResults
|
||||||
isFetchingSearchResults={isFetchingSearchResults}
|
isFetchingSearchResults={isFetchingSearchResults || isDynamicLoading}
|
||||||
searchResults={searchResults}
|
searchResults={searchResults}
|
||||||
searchQuery={searchQuery}
|
searchQuery={searchQuery}
|
||||||
/>
|
/>
|
||||||
@@ -148,35 +162,65 @@ const RenderResults = ({ isFetchingSearchResults, searchResults, searchQuery }:
|
|||||||
|
|
||||||
const dashboardsSectionTitle = t('command-palette.section.dashboard-search-results', 'Dashboards');
|
const dashboardsSectionTitle = t('command-palette.section.dashboard-search-results', 'Dashboards');
|
||||||
const foldersSectionTitle = t('command-palette.section.folder-search-results', 'Folders');
|
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
|
// Group search results by section (dashboard, folder, or dynamic plugin sections)
|
||||||
const dashboardResultItems = useMemo(
|
const groupedSearchResults = useMemo(() => {
|
||||||
() =>
|
const groups = new Map<string, ActionImpl[]>();
|
||||||
searchResults
|
|
||||||
.filter((item) => item.id.startsWith('go/dashboard'))
|
searchResults.forEach((item) => {
|
||||||
.map((dashboard) => new ActionImpl(dashboard, { store: {} })),
|
let section: string;
|
||||||
[searchResults]
|
if (item.id.startsWith('go/dashboard')) {
|
||||||
);
|
section = dashboardsSectionTitle;
|
||||||
const folderResultItems = useMemo(
|
} else if (item.id.startsWith('go/folder')) {
|
||||||
() =>
|
section = foldersSectionTitle;
|
||||||
searchResults
|
} else {
|
||||||
.filter((item) => item.id.startsWith('go/folder'))
|
// Dynamic results have their section set
|
||||||
.map((folder) => new ActionImpl(folder, { store: {} })),
|
// Section can be a string or { name: string; priority: number; }
|
||||||
[searchResults]
|
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 items = useMemo(() => {
|
||||||
const results = [...kbarResults];
|
const results = [...kbarResults];
|
||||||
if (folderResultItems.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(foldersSectionTitle);
|
||||||
results.push(...folderResultItems);
|
results.push(...folderResults);
|
||||||
}
|
}
|
||||||
if (dashboardResultItems.length > 0) {
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const dashboardResults = groupedSearchResults.get(dashboardsSectionTitle) ?? [];
|
||||||
|
if (dashboardResults.length > 0) {
|
||||||
results.push(dashboardsSectionTitle);
|
results.push(dashboardsSectionTitle);
|
||||||
results.push(...dashboardResultItems);
|
results.push(...dashboardResults);
|
||||||
}
|
}
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
}, [kbarResults, dashboardsSectionTitle, dashboardResultItems, foldersSectionTitle, folderResultItems]);
|
}, [kbarResults, groupedSearchResults, dashboardsSectionTitle, foldersSectionTitle]);
|
||||||
|
|
||||||
const showEmptyState = !isFetchingSearchResults && items.length === 0;
|
const showEmptyState = !isFetchingSearchResults && items.length === 0;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -0,0 +1,810 @@
|
|||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
|
||||||
|
import { PluginExtensionCommandPaletteContext } from '@grafana/data';
|
||||||
|
|
||||||
|
import { CommandPaletteDynamicRegistry } from './CommandPaletteDynamicRegistry';
|
||||||
|
|
||||||
|
describe('CommandPaletteDynamicRegistry', () => {
|
||||||
|
const pluginId = 'test-plugin';
|
||||||
|
|
||||||
|
describe('Registry Management', () => {
|
||||||
|
it('should return empty registry when no extensions registered', async () => {
|
||||||
|
const registry = new CommandPaletteDynamicRegistry();
|
||||||
|
const observable = registry.asObservable();
|
||||||
|
const state = await firstValueFrom(observable);
|
||||||
|
expect(state).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be possible to register command palette dynamic providers', async () => {
|
||||||
|
const registry = new CommandPaletteDynamicRegistry();
|
||||||
|
const mockSearchProvider = jest.fn().mockResolvedValue([]);
|
||||||
|
|
||||||
|
registry.register({
|
||||||
|
pluginId,
|
||||||
|
configs: [
|
||||||
|
{
|
||||||
|
searchProvider: mockSearchProvider,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = await registry.getState();
|
||||||
|
expect(state).toEqual({
|
||||||
|
[`${pluginId}/0`]: [
|
||||||
|
{
|
||||||
|
pluginId,
|
||||||
|
config: {
|
||||||
|
searchProvider: mockSearchProvider,
|
||||||
|
category: undefined,
|
||||||
|
minQueryLength: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply default values for optional config properties', async () => {
|
||||||
|
const registry = new CommandPaletteDynamicRegistry();
|
||||||
|
const mockSearchProvider = jest.fn().mockResolvedValue([]);
|
||||||
|
|
||||||
|
registry.register({
|
||||||
|
pluginId,
|
||||||
|
configs: [
|
||||||
|
{
|
||||||
|
searchProvider: mockSearchProvider,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = await registry.getState();
|
||||||
|
const item = state[`${pluginId}/0`][0];
|
||||||
|
|
||||||
|
expect(item.config.category).toBeUndefined();
|
||||||
|
expect(item.config.minQueryLength).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve custom config values when provided', async () => {
|
||||||
|
const registry = new CommandPaletteDynamicRegistry();
|
||||||
|
const mockSearchProvider = jest.fn().mockResolvedValue([]);
|
||||||
|
|
||||||
|
registry.register({
|
||||||
|
pluginId,
|
||||||
|
configs: [
|
||||||
|
{
|
||||||
|
searchProvider: mockSearchProvider,
|
||||||
|
category: 'Custom Category',
|
||||||
|
minQueryLength: 5,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = await registry.getState();
|
||||||
|
const item = state[`${pluginId}/0`][0];
|
||||||
|
|
||||||
|
expect(item.config.category).toBe('Custom Category');
|
||||||
|
expect(item.config.minQueryLength).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should register multiple providers from the same plugin', async () => {
|
||||||
|
const registry = new CommandPaletteDynamicRegistry();
|
||||||
|
const mockSearchProvider1 = jest.fn().mockResolvedValue([]);
|
||||||
|
const mockSearchProvider2 = jest.fn().mockResolvedValue([]);
|
||||||
|
|
||||||
|
registry.register({
|
||||||
|
pluginId,
|
||||||
|
configs: [
|
||||||
|
{
|
||||||
|
searchProvider: mockSearchProvider1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
searchProvider: mockSearchProvider2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = await registry.getState();
|
||||||
|
expect(Object.keys(state)).toHaveLength(2);
|
||||||
|
expect(state[`${pluginId}/0`]).toBeDefined();
|
||||||
|
expect(state[`${pluginId}/1`]).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should notify subscribers when the registry changes', async () => {
|
||||||
|
const registry = new CommandPaletteDynamicRegistry();
|
||||||
|
const observable = registry.asObservable();
|
||||||
|
const subscribeCallback = jest.fn();
|
||||||
|
|
||||||
|
observable.subscribe(subscribeCallback);
|
||||||
|
|
||||||
|
registry.register({
|
||||||
|
pluginId,
|
||||||
|
configs: [
|
||||||
|
{
|
||||||
|
searchProvider: jest.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(subscribeCallback).toHaveBeenCalledTimes(2); // initial empty state + registration
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not be possible to register on a read-only registry', async () => {
|
||||||
|
const registry = new CommandPaletteDynamicRegistry();
|
||||||
|
const readOnlyRegistry = new CommandPaletteDynamicRegistry({
|
||||||
|
registrySubject: registry['registrySubject'],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
readOnlyRegistry.register({
|
||||||
|
pluginId,
|
||||||
|
configs: [
|
||||||
|
{
|
||||||
|
searchProvider: jest.fn(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}).toThrow('Cannot register to a read-only registry');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create a read-only version of the registry', async () => {
|
||||||
|
const registry = new CommandPaletteDynamicRegistry();
|
||||||
|
const readOnlyRegistry = registry.readOnly();
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
readOnlyRegistry.register({
|
||||||
|
pluginId,
|
||||||
|
configs: [
|
||||||
|
{
|
||||||
|
searchProvider: jest.fn(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}).toThrow('Cannot register to a read-only registry');
|
||||||
|
|
||||||
|
const currentState = await readOnlyRegistry.getState();
|
||||||
|
expect(Object.keys(currentState)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass down fresh registrations to the read-only version of the registry', async () => {
|
||||||
|
const registry = new CommandPaletteDynamicRegistry();
|
||||||
|
const readOnlyRegistry = registry.readOnly();
|
||||||
|
const subscribeCallback = jest.fn();
|
||||||
|
let readOnlyState;
|
||||||
|
|
||||||
|
// Should have no providers registered in the beginning
|
||||||
|
readOnlyState = await readOnlyRegistry.getState();
|
||||||
|
expect(Object.keys(readOnlyState)).toHaveLength(0);
|
||||||
|
|
||||||
|
readOnlyRegistry.asObservable().subscribe(subscribeCallback);
|
||||||
|
|
||||||
|
// Register a provider to the original (writable) registry
|
||||||
|
registry.register({
|
||||||
|
pluginId,
|
||||||
|
configs: [
|
||||||
|
{
|
||||||
|
searchProvider: jest.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// The read-only registry should have received the new provider
|
||||||
|
readOnlyState = await readOnlyRegistry.getState();
|
||||||
|
expect(Object.keys(readOnlyState)).toHaveLength(1);
|
||||||
|
|
||||||
|
expect(subscribeCallback).toHaveBeenCalledTimes(2); // initial empty + registration
|
||||||
|
expect(Object.keys(subscribeCallback.mock.calls[1][0])).toEqual([`${pluginId}/0`]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Config Validation', () => {
|
||||||
|
let consoleErrorSpy: jest.SpyInstance;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not register provider without searchProvider', async () => {
|
||||||
|
const registry = new CommandPaletteDynamicRegistry();
|
||||||
|
|
||||||
|
registry.register({
|
||||||
|
pluginId,
|
||||||
|
configs: [
|
||||||
|
{
|
||||||
|
// @ts-ignore - testing invalid config
|
||||||
|
searchProvider: undefined,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = await registry.getState();
|
||||||
|
expect(Object.keys(state)).toHaveLength(0);
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not register provider with non-function searchProvider', async () => {
|
||||||
|
const registry = new CommandPaletteDynamicRegistry();
|
||||||
|
|
||||||
|
registry.register({
|
||||||
|
pluginId,
|
||||||
|
configs: [
|
||||||
|
{
|
||||||
|
// @ts-ignore - testing invalid config
|
||||||
|
searchProvider: 'not-a-function',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = await registry.getState();
|
||||||
|
expect(Object.keys(state)).toHaveLength(0);
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should log provider registration in dev mode', async () => {
|
||||||
|
const originalNodeEnv = process.env.NODE_ENV;
|
||||||
|
process.env.NODE_ENV = 'development';
|
||||||
|
const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation();
|
||||||
|
|
||||||
|
const registry = new CommandPaletteDynamicRegistry();
|
||||||
|
|
||||||
|
registry.register({
|
||||||
|
pluginId,
|
||||||
|
configs: [
|
||||||
|
{
|
||||||
|
searchProvider: jest.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await registry.getState();
|
||||||
|
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Registered provider: test-plugin/0'));
|
||||||
|
|
||||||
|
consoleLogSpy.mockRestore();
|
||||||
|
process.env.NODE_ENV = originalNodeEnv;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Search Functionality', () => {
|
||||||
|
it('should execute search across all registered providers', async () => {
|
||||||
|
const registry = new CommandPaletteDynamicRegistry();
|
||||||
|
const mockSearchProvider1 = jest.fn().mockResolvedValue([{ id: 'result1', title: 'Result 1' }]);
|
||||||
|
const mockSearchProvider2 = jest.fn().mockResolvedValue([{ id: 'result2', title: 'Result 2' }]);
|
||||||
|
|
||||||
|
registry.register({
|
||||||
|
pluginId: 'plugin1',
|
||||||
|
configs: [
|
||||||
|
{
|
||||||
|
searchProvider: mockSearchProvider1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
registry.register({
|
||||||
|
pluginId: 'plugin2',
|
||||||
|
configs: [
|
||||||
|
{
|
||||||
|
searchProvider: mockSearchProvider2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const context: PluginExtensionCommandPaletteContext = {
|
||||||
|
searchQuery: 'test query',
|
||||||
|
};
|
||||||
|
|
||||||
|
const results = await registry.search(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);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass context with AbortSignal to search providers', async () => {
|
||||||
|
const registry = new CommandPaletteDynamicRegistry();
|
||||||
|
const mockSearchProvider = jest.fn().mockResolvedValue([]);
|
||||||
|
|
||||||
|
registry.register({
|
||||||
|
pluginId,
|
||||||
|
configs: [
|
||||||
|
{
|
||||||
|
searchProvider: mockSearchProvider,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const context: PluginExtensionCommandPaletteContext = {
|
||||||
|
searchQuery: 'test',
|
||||||
|
signal: abortController.signal,
|
||||||
|
};
|
||||||
|
|
||||||
|
await registry.search(context);
|
||||||
|
|
||||||
|
expect(mockSearchProvider).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
searchQuery: 'test',
|
||||||
|
signal: expect.any(Object),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not search when query is below minimum length', async () => {
|
||||||
|
const registry = new CommandPaletteDynamicRegistry();
|
||||||
|
const mockSearchProvider = jest.fn().mockResolvedValue([]);
|
||||||
|
|
||||||
|
registry.register({
|
||||||
|
pluginId,
|
||||||
|
configs: [
|
||||||
|
{
|
||||||
|
searchProvider: mockSearchProvider,
|
||||||
|
minQueryLength: 3,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const context: PluginExtensionCommandPaletteContext = {
|
||||||
|
searchQuery: 'ab', // Only 2 characters
|
||||||
|
};
|
||||||
|
|
||||||
|
await registry.search(context);
|
||||||
|
|
||||||
|
expect(mockSearchProvider).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should search when query meets minimum length', async () => {
|
||||||
|
const registry = new CommandPaletteDynamicRegistry();
|
||||||
|
const mockSearchProvider = jest.fn().mockResolvedValue([]);
|
||||||
|
|
||||||
|
registry.register({
|
||||||
|
pluginId,
|
||||||
|
configs: [
|
||||||
|
{
|
||||||
|
searchProvider: mockSearchProvider,
|
||||||
|
minQueryLength: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const context: PluginExtensionCommandPaletteContext = {
|
||||||
|
searchQuery: 'ab', // Exactly 2 characters
|
||||||
|
};
|
||||||
|
|
||||||
|
await registry.search(context);
|
||||||
|
|
||||||
|
expect(mockSearchProvider).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should limit results to 5 items per provider', async () => {
|
||||||
|
const registry = new CommandPaletteDynamicRegistry();
|
||||||
|
const mockSearchProvider = jest.fn().mockResolvedValue([
|
||||||
|
{ id: '1', title: 'Result 1' },
|
||||||
|
{ id: '2', title: 'Result 2' },
|
||||||
|
{ id: '3', title: 'Result 3' },
|
||||||
|
{ id: '4', title: 'Result 4' },
|
||||||
|
{ id: '5', title: 'Result 5' },
|
||||||
|
{ id: '6', title: 'Result 6' },
|
||||||
|
{ id: '7', title: 'Result 7' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
registry.register({
|
||||||
|
pluginId,
|
||||||
|
configs: [
|
||||||
|
{
|
||||||
|
searchProvider: mockSearchProvider,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const context: PluginExtensionCommandPaletteContext = {
|
||||||
|
searchQuery: 'test',
|
||||||
|
};
|
||||||
|
|
||||||
|
const results = await registry.search(context);
|
||||||
|
const searchResult = results.get(`${pluginId}/0`);
|
||||||
|
|
||||||
|
expect(searchResult?.items).toHaveLength(5);
|
||||||
|
expect(searchResult?.items[4].id).toBe('5');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Result Validation', () => {
|
||||||
|
let consoleWarnSpy: jest.SpyInstance;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
consoleWarnSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter out results without id', async () => {
|
||||||
|
const registry = new CommandPaletteDynamicRegistry();
|
||||||
|
const mockSearchProvider = jest.fn().mockResolvedValue([
|
||||||
|
{ id: 'valid', title: 'Valid Result' },
|
||||||
|
// @ts-ignore - testing invalid result
|
||||||
|
{ title: 'Invalid Result' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
registry.register({
|
||||||
|
pluginId,
|
||||||
|
configs: [
|
||||||
|
{
|
||||||
|
searchProvider: mockSearchProvider,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const context: PluginExtensionCommandPaletteContext = {
|
||||||
|
searchQuery: 'test',
|
||||||
|
};
|
||||||
|
|
||||||
|
const results = await registry.search(context);
|
||||||
|
const searchResult = results.get(`${pluginId}/0`);
|
||||||
|
|
||||||
|
expect(searchResult?.items).toHaveLength(1);
|
||||||
|
expect(searchResult?.items[0].id).toBe('valid');
|
||||||
|
expect(consoleWarnSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter out results with non-string id', async () => {
|
||||||
|
const registry = new CommandPaletteDynamicRegistry();
|
||||||
|
const mockSearchProvider = jest.fn().mockResolvedValue([
|
||||||
|
{ id: 'valid', title: 'Valid Result' },
|
||||||
|
// @ts-ignore - testing invalid result
|
||||||
|
{ id: 123, title: 'Invalid Result' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
registry.register({
|
||||||
|
pluginId,
|
||||||
|
configs: [
|
||||||
|
{
|
||||||
|
searchProvider: mockSearchProvider,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const context: PluginExtensionCommandPaletteContext = {
|
||||||
|
searchQuery: 'test',
|
||||||
|
};
|
||||||
|
|
||||||
|
const results = await registry.search(context);
|
||||||
|
const searchResult = results.get(`${pluginId}/0`);
|
||||||
|
|
||||||
|
expect(searchResult?.items).toHaveLength(1);
|
||||||
|
expect(searchResult?.items[0].id).toBe('valid');
|
||||||
|
expect(consoleWarnSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter out results without title', async () => {
|
||||||
|
const registry = new CommandPaletteDynamicRegistry();
|
||||||
|
const mockSearchProvider = jest.fn().mockResolvedValue([
|
||||||
|
{ id: 'valid', title: 'Valid Result' },
|
||||||
|
// @ts-ignore - testing invalid result
|
||||||
|
{ id: 'invalid' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
registry.register({
|
||||||
|
pluginId,
|
||||||
|
configs: [
|
||||||
|
{
|
||||||
|
searchProvider: mockSearchProvider,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const context: PluginExtensionCommandPaletteContext = {
|
||||||
|
searchQuery: 'test',
|
||||||
|
};
|
||||||
|
|
||||||
|
const results = await registry.search(context);
|
||||||
|
const searchResult = results.get(`${pluginId}/0`);
|
||||||
|
|
||||||
|
expect(searchResult?.items).toHaveLength(1);
|
||||||
|
expect(searchResult?.items[0].id).toBe('valid');
|
||||||
|
expect(consoleWarnSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter out results with non-string title', async () => {
|
||||||
|
const registry = new CommandPaletteDynamicRegistry();
|
||||||
|
const mockSearchProvider = jest.fn().mockResolvedValue([
|
||||||
|
{ id: 'valid', title: 'Valid Result' },
|
||||||
|
// @ts-ignore - testing invalid result
|
||||||
|
{ id: 'invalid', title: 123 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
registry.register({
|
||||||
|
pluginId,
|
||||||
|
configs: [
|
||||||
|
{
|
||||||
|
searchProvider: mockSearchProvider,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const context: PluginExtensionCommandPaletteContext = {
|
||||||
|
searchQuery: 'test',
|
||||||
|
};
|
||||||
|
|
||||||
|
const results = await registry.search(context);
|
||||||
|
const searchResult = results.get(`${pluginId}/0`);
|
||||||
|
|
||||||
|
expect(searchResult?.items).toHaveLength(1);
|
||||||
|
expect(searchResult?.items[0].id).toBe('valid');
|
||||||
|
expect(consoleWarnSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not include provider in results if no valid items', async () => {
|
||||||
|
const registry = new CommandPaletteDynamicRegistry();
|
||||||
|
const mockSearchProvider = jest.fn().mockResolvedValue([
|
||||||
|
// @ts-ignore - testing invalid results
|
||||||
|
{ id: 'invalid' }, // missing title
|
||||||
|
]);
|
||||||
|
|
||||||
|
registry.register({
|
||||||
|
pluginId,
|
||||||
|
configs: [
|
||||||
|
{
|
||||||
|
searchProvider: mockSearchProvider,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const context: PluginExtensionCommandPaletteContext = {
|
||||||
|
searchQuery: 'test',
|
||||||
|
};
|
||||||
|
|
||||||
|
const results = await registry.search(context);
|
||||||
|
|
||||||
|
expect(results.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should warn if provider returns non-array', async () => {
|
||||||
|
const registry = new CommandPaletteDynamicRegistry();
|
||||||
|
// @ts-ignore - testing invalid return value
|
||||||
|
const mockSearchProvider = jest.fn().mockResolvedValue('not-an-array');
|
||||||
|
|
||||||
|
registry.register({
|
||||||
|
pluginId,
|
||||||
|
configs: [
|
||||||
|
{
|
||||||
|
searchProvider: mockSearchProvider,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const context: PluginExtensionCommandPaletteContext = {
|
||||||
|
searchQuery: 'test',
|
||||||
|
};
|
||||||
|
|
||||||
|
const results = await registry.search(context);
|
||||||
|
|
||||||
|
expect(results.size).toBe(0);
|
||||||
|
expect(consoleWarnSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Error Handling', () => {
|
||||||
|
let consoleErrorSpy: jest.SpyInstance;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle search provider errors gracefully', async () => {
|
||||||
|
const registry = new CommandPaletteDynamicRegistry();
|
||||||
|
const mockSearchProvider = jest.fn().mockRejectedValue(new Error('Search failed'));
|
||||||
|
|
||||||
|
registry.register({
|
||||||
|
pluginId,
|
||||||
|
configs: [
|
||||||
|
{
|
||||||
|
searchProvider: mockSearchProvider,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const context: PluginExtensionCommandPaletteContext = {
|
||||||
|
searchQuery: 'test',
|
||||||
|
};
|
||||||
|
|
||||||
|
const results = await registry.search(context);
|
||||||
|
|
||||||
|
expect(results.size).toBe(0);
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('Search failed'),
|
||||||
|
expect.objectContaining({
|
||||||
|
error: 'Error: Search failed',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not log AbortErrors', async () => {
|
||||||
|
const registry = new CommandPaletteDynamicRegistry();
|
||||||
|
const abortError = new Error('Aborted');
|
||||||
|
abortError.name = 'AbortError';
|
||||||
|
const mockSearchProvider = jest.fn().mockRejectedValue(abortError);
|
||||||
|
|
||||||
|
registry.register({
|
||||||
|
pluginId,
|
||||||
|
configs: [
|
||||||
|
{
|
||||||
|
searchProvider: mockSearchProvider,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const context: PluginExtensionCommandPaletteContext = {
|
||||||
|
searchQuery: 'test',
|
||||||
|
};
|
||||||
|
|
||||||
|
await registry.search(context);
|
||||||
|
|
||||||
|
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should continue searching other providers if one fails', async () => {
|
||||||
|
const registry = new CommandPaletteDynamicRegistry();
|
||||||
|
const failingProvider = jest.fn().mockRejectedValue(new Error('Failed'));
|
||||||
|
const successProvider = jest.fn().mockResolvedValue([{ id: 'success', title: 'Success Result' }]);
|
||||||
|
|
||||||
|
registry.register({
|
||||||
|
pluginId: 'failing-plugin',
|
||||||
|
configs: [
|
||||||
|
{
|
||||||
|
searchProvider: failingProvider,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
registry.register({
|
||||||
|
pluginId: 'success-plugin',
|
||||||
|
configs: [
|
||||||
|
{
|
||||||
|
searchProvider: successProvider,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const context: PluginExtensionCommandPaletteContext = {
|
||||||
|
searchQuery: 'test',
|
||||||
|
};
|
||||||
|
|
||||||
|
const results = await registry.search(context);
|
||||||
|
|
||||||
|
expect(results.size).toBe(1);
|
||||||
|
expect(results.get('success-plugin/0')).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Search Context', () => {
|
||||||
|
it('should handle empty search query', async () => {
|
||||||
|
const registry = new CommandPaletteDynamicRegistry();
|
||||||
|
const mockSearchProvider = jest.fn().mockResolvedValue([]);
|
||||||
|
|
||||||
|
registry.register({
|
||||||
|
pluginId,
|
||||||
|
configs: [
|
||||||
|
{
|
||||||
|
searchProvider: mockSearchProvider,
|
||||||
|
minQueryLength: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const context: PluginExtensionCommandPaletteContext = {
|
||||||
|
searchQuery: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
await registry.search(context);
|
||||||
|
|
||||||
|
expect(mockSearchProvider).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle undefined search query', async () => {
|
||||||
|
const registry = new CommandPaletteDynamicRegistry();
|
||||||
|
const mockSearchProvider = jest.fn().mockResolvedValue([]);
|
||||||
|
|
||||||
|
registry.register({
|
||||||
|
pluginId,
|
||||||
|
configs: [
|
||||||
|
{
|
||||||
|
searchProvider: mockSearchProvider,
|
||||||
|
minQueryLength: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const context: PluginExtensionCommandPaletteContext = {};
|
||||||
|
|
||||||
|
await registry.search(context);
|
||||||
|
|
||||||
|
expect(mockSearchProvider).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Result Structure', () => {
|
||||||
|
it('should return results with correct structure', async () => {
|
||||||
|
const registry = new CommandPaletteDynamicRegistry();
|
||||||
|
const mockSearchProvider = jest.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'test-id',
|
||||||
|
title: 'Test Title',
|
||||||
|
description: 'Test Description',
|
||||||
|
path: '/test/path',
|
||||||
|
keywords: ['test', 'keyword'],
|
||||||
|
section: 'Test Section',
|
||||||
|
data: { custom: 'data' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
registry.register({
|
||||||
|
pluginId,
|
||||||
|
configs: [
|
||||||
|
{
|
||||||
|
searchProvider: mockSearchProvider,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const context: PluginExtensionCommandPaletteContext = {
|
||||||
|
searchQuery: 'test',
|
||||||
|
};
|
||||||
|
|
||||||
|
const results = await registry.search(context);
|
||||||
|
const searchResult = results.get(`${pluginId}/0`);
|
||||||
|
|
||||||
|
expect(searchResult?.items[0]).toEqual({
|
||||||
|
id: 'test-id',
|
||||||
|
title: 'Test Title',
|
||||||
|
description: 'Test Description',
|
||||||
|
path: '/test/path',
|
||||||
|
keywords: ['test', 'keyword'],
|
||||||
|
section: 'Test Section',
|
||||||
|
data: { custom: 'data' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include config information in search result', async () => {
|
||||||
|
const registry = new CommandPaletteDynamicRegistry();
|
||||||
|
const mockSearchProvider = jest.fn().mockResolvedValue([{ id: 'test', title: 'Test' }]);
|
||||||
|
|
||||||
|
registry.register({
|
||||||
|
pluginId,
|
||||||
|
configs: [
|
||||||
|
{
|
||||||
|
searchProvider: mockSearchProvider,
|
||||||
|
category: 'Test Category',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const context: PluginExtensionCommandPaletteContext = {
|
||||||
|
searchQuery: 'test',
|
||||||
|
};
|
||||||
|
|
||||||
|
const results = await registry.search(context);
|
||||||
|
const searchResult = results.get(`${pluginId}/0`);
|
||||||
|
|
||||||
|
expect(searchResult?.config.pluginId).toBe(pluginId);
|
||||||
|
expect(searchResult?.config.config.category).toBe('Test Category');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import { ReplaySubject, Subject, firstValueFrom, map, scan, startWith } from 'rxjs';
|
||||||
|
|
||||||
|
import {
|
||||||
|
PluginExtensionCommandPaletteDynamicConfig,
|
||||||
|
CommandPaletteDynamicResult,
|
||||||
|
DynamicPluginExtensionCommandPaletteContext,
|
||||||
|
PluginExtensionCommandPaletteContext,
|
||||||
|
} from '@grafana/data';
|
||||||
|
|
||||||
|
import { deepFreeze } from '../plugins/extensions/utils';
|
||||||
|
|
||||||
|
const logPrefix = '[CommandPaletteDynamic]';
|
||||||
|
|
||||||
|
export interface CommandPaletteDynamicRegistryItem {
|
||||||
|
pluginId: string;
|
||||||
|
config: PluginExtensionCommandPaletteDynamicConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommandPaletteDynamicSearchResult {
|
||||||
|
items: CommandPaletteDynamicResult[];
|
||||||
|
config: CommandPaletteDynamicRegistryItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PluginExtensionConfigs = {
|
||||||
|
pluginId: string;
|
||||||
|
configs: PluginExtensionCommandPaletteDynamicConfig[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type RegistryType = Record<string, CommandPaletteDynamicRegistryItem[]>;
|
||||||
|
|
||||||
|
const MSG_CANNOT_REGISTER_READ_ONLY = 'Cannot register to a read-only registry';
|
||||||
|
|
||||||
|
export class CommandPaletteDynamicRegistry {
|
||||||
|
private isReadOnly: boolean;
|
||||||
|
private resultSubject: Subject<PluginExtensionConfigs>;
|
||||||
|
private registrySubject: ReplaySubject<RegistryType>;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
options: {
|
||||||
|
registrySubject?: ReplaySubject<RegistryType>;
|
||||||
|
initialState?: RegistryType;
|
||||||
|
} = {}
|
||||||
|
) {
|
||||||
|
this.resultSubject = new Subject<PluginExtensionConfigs>();
|
||||||
|
this.isReadOnly = false;
|
||||||
|
|
||||||
|
// If the registry subject is provided, it's a read-only instance
|
||||||
|
if (options.registrySubject) {
|
||||||
|
this.registrySubject = options.registrySubject;
|
||||||
|
this.isReadOnly = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.registrySubject = new ReplaySubject<RegistryType>(1);
|
||||||
|
this.resultSubject
|
||||||
|
.pipe(
|
||||||
|
scan(this.mapToRegistry.bind(this), options.initialState ?? {}),
|
||||||
|
startWith(options.initialState ?? {}),
|
||||||
|
map((registry) => deepFreeze(registry))
|
||||||
|
)
|
||||||
|
.subscribe(this.registrySubject);
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapToRegistry(registry: RegistryType, item: PluginExtensionConfigs): RegistryType {
|
||||||
|
const { pluginId, configs } = item;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use index to differentiate multiple providers from same plugin.
|
||||||
|
// Note: Provider IDs are index-based, so changing the order of configs
|
||||||
|
// in addCommandPaletteDynamicProvider calls could affect result tracking.
|
||||||
|
// Plugins should maintain consistent ordering of their providers.
|
||||||
|
const providerId = `${pluginId}/${index}`;
|
||||||
|
|
||||||
|
if (!(providerId in registry)) {
|
||||||
|
registry[providerId] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
registry[providerId].push({
|
||||||
|
pluginId,
|
||||||
|
config: {
|
||||||
|
...config,
|
||||||
|
category: category,
|
||||||
|
minQueryLength: config.minQueryLength ?? 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
console.log(`${logPrefix} Registered provider: ${providerId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return registry;
|
||||||
|
}
|
||||||
|
|
||||||
|
register(result: PluginExtensionConfigs): void {
|
||||||
|
if (this.isReadOnly) {
|
||||||
|
throw new Error(MSG_CANNOT_REGISTER_READ_ONLY);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.resultSubject.next(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
asObservable() {
|
||||||
|
return this.registrySubject.asObservable();
|
||||||
|
}
|
||||||
|
|
||||||
|
getState(): Promise<RegistryType> {
|
||||||
|
return firstValueFrom(this.asObservable());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 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) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = registryItems[0]; // Take first config per provider
|
||||||
|
const { config } = item;
|
||||||
|
|
||||||
|
// Check minimum query length
|
||||||
|
if (searchQuery.length < (config.minQueryLength ?? 2)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const items = await config.searchProvider(dynamicContext);
|
||||||
|
|
||||||
|
// Validate results
|
||||||
|
if (!Array.isArray(items)) {
|
||||||
|
console.warn(`${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') {
|
||||||
|
console.warn(`${logPrefix} Provider ${providerId}: result missing id`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!item.title || typeof item.title !== 'string') {
|
||||||
|
console.warn(`${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;
|
||||||
|
}
|
||||||
|
console.error(`${logPrefix} Search failed for ${providerId}`, { error: String(error) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(searchPromises);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a read-only version of the registry.
|
||||||
|
*/
|
||||||
|
readOnly() {
|
||||||
|
return new CommandPaletteDynamicRegistry({
|
||||||
|
registrySubject: this.registrySubject,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global instance of the Command Palette Dynamic Registry
|
||||||
|
* This registry is used to manage dynamic command palette providers from plugins
|
||||||
|
*/
|
||||||
|
export const commandPaletteDynamicRegistry = new CommandPaletteDynamicRegistry();
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useDebounce } from 'react-use';
|
||||||
|
|
||||||
|
import { CommandPaletteDynamicResult, PluginExtensionCommandPaletteContext } from '@grafana/data';
|
||||||
|
import { appEvents } from 'app/core/app_events';
|
||||||
|
import { CloseExtensionSidebarEvent, OpenExtensionSidebarEvent, ToggleExtensionSidebarEvent } from 'app/types/events';
|
||||||
|
|
||||||
|
import { createOpenModalFunction } from '../../plugins/extensions/utils';
|
||||||
|
import { commandPaletteDynamicRegistry, CommandPaletteDynamicSearchResult } from '../CommandPaletteDynamicRegistry';
|
||||||
|
import { CommandPaletteAction } from '../types';
|
||||||
|
import { EXTENSIONS_PRIORITY } from '../values';
|
||||||
|
|
||||||
|
interface DynamicResultWithPluginId extends CommandPaletteDynamicResult {
|
||||||
|
pluginId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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: CommandPaletteAction[];
|
||||||
|
isLoading: boolean;
|
||||||
|
} {
|
||||||
|
const [dynamicResults, setDynamicResults] = useState<DynamicResultWithPluginId[]>([]);
|
||||||
|
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: Map<string, CommandPaletteDynamicSearchResult>) => {
|
||||||
|
if (abortController.signal.aborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allResults: DynamicResultWithPluginId[] = [];
|
||||||
|
|
||||||
|
resultsMap.forEach(({ items, config }: CommandPaletteDynamicSearchResult) => {
|
||||||
|
items.forEach((item: CommandPaletteDynamicResult) => {
|
||||||
|
allResults.push({
|
||||||
|
...item,
|
||||||
|
pluginId: config.pluginId,
|
||||||
|
// Use item's section or fall back to config's category
|
||||||
|
section: item.section ?? config.config.category,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
setDynamicResults(allResults);
|
||||||
|
setIsLoading(false);
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
if (!abortController.signal.aborted) {
|
||||||
|
console.error('[CommandPalette] Dynamic search failed:', error);
|
||||||
|
setDynamicResults([]);
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
return () => {
|
||||||
|
abortController.abort();
|
||||||
|
};
|
||||||
|
}, [debouncedSearchQuery]);
|
||||||
|
|
||||||
|
// Convert dynamic results to CommandPaletteAction[]
|
||||||
|
const results: CommandPaletteAction[] = useMemo(() => {
|
||||||
|
return dynamicResults.map((result) => {
|
||||||
|
const section = result.section ?? 'Dynamic Results';
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `dynamic-${result.pluginId}-${result.id}`,
|
||||||
|
name: result.title,
|
||||||
|
section,
|
||||||
|
subtitle: result.description,
|
||||||
|
priority: EXTENSIONS_PRIORITY - 0.5,
|
||||||
|
keywords: result.keywords?.join(' '),
|
||||||
|
perform: () => {
|
||||||
|
if (result.onSelect) {
|
||||||
|
const extensionPointId = 'grafana/commandpalette/action';
|
||||||
|
result.onSelect(result, {
|
||||||
|
context: { searchQuery: debouncedSearchQuery, signal: new AbortController().signal },
|
||||||
|
extensionPointId,
|
||||||
|
openModal: createOpenModalFunction({
|
||||||
|
pluginId: result.pluginId,
|
||||||
|
title: result.title,
|
||||||
|
description: result.description,
|
||||||
|
extensionPointId,
|
||||||
|
path: result.path,
|
||||||
|
category: result.section,
|
||||||
|
}),
|
||||||
|
openSidebar: (componentTitle, context) => {
|
||||||
|
appEvents.publish(
|
||||||
|
new OpenExtensionSidebarEvent({
|
||||||
|
props: context,
|
||||||
|
pluginId: result.pluginId,
|
||||||
|
componentTitle,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
closeSidebar: () => {
|
||||||
|
appEvents.publish(new CloseExtensionSidebarEvent());
|
||||||
|
},
|
||||||
|
toggleSidebar: (componentTitle, context) => {
|
||||||
|
appEvents.publish(
|
||||||
|
new ToggleExtensionSidebarEvent({
|
||||||
|
props: context,
|
||||||
|
pluginId: result.pluginId,
|
||||||
|
componentTitle,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
url: result.path,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [dynamicResults, debouncedSearchQuery]);
|
||||||
|
|
||||||
|
return { results, isLoading };
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
throwIfAngular,
|
throwIfAngular,
|
||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
import { config } from '@grafana/runtime';
|
import { config } from '@grafana/runtime';
|
||||||
|
import { commandPaletteDynamicRegistry } from 'app/features/commandPalette/CommandPaletteDynamicRegistry';
|
||||||
import { GenericDataSourcePlugin } from 'app/features/datasources/types';
|
import { GenericDataSourcePlugin } from 'app/features/datasources/types';
|
||||||
import { getPanelPluginLoadError } from 'app/features/panel/components/PanelPluginError';
|
import { getPanelPluginLoadError } from 'app/features/panel/components/PanelPluginError';
|
||||||
|
|
||||||
@@ -102,6 +103,7 @@ const appPluginPostImport: PostImportStrategy<AppPlugin, AppPluginMeta> = async
|
|||||||
addedComponentsRegistry.register({ pluginId: meta.id, configs: plugin.addedComponentConfigs || [] });
|
addedComponentsRegistry.register({ pluginId: meta.id, configs: plugin.addedComponentConfigs || [] });
|
||||||
addedLinksRegistry.register({ pluginId: meta.id, configs: plugin.addedLinkConfigs || [] });
|
addedLinksRegistry.register({ pluginId: meta.id, configs: plugin.addedLinkConfigs || [] });
|
||||||
addedFunctionsRegistry.register({ pluginId: meta.id, configs: plugin.addedFunctionConfigs || [] });
|
addedFunctionsRegistry.register({ pluginId: meta.id, configs: plugin.addedFunctionConfigs || [] });
|
||||||
|
commandPaletteDynamicRegistry.register({ pluginId: meta.id, configs: plugin.commandPaletteDynamicConfigs || [] });
|
||||||
|
|
||||||
pluginsCache.set(meta.id, plugin);
|
pluginsCache.set(meta.id, plugin);
|
||||||
return plugin;
|
return plugin;
|
||||||
|
|||||||
Reference in New Issue
Block a user