feat(dynamic command palette api): moving the code to command palette folder
This commit is contained in:
+31
-10
@@ -23,10 +23,11 @@ temp_data_lifetime = 24h
|
||||
logs = data/log
|
||||
|
||||
# Directory where grafana will automatically scan and look for plugins
|
||||
plugins = data/plugins
|
||||
plugins = ../asserts-app-plugin
|
||||
|
||||
|
||||
# folder that contains provisioning config files that grafana will apply on startup and while running.
|
||||
provisioning = conf/provisioning
|
||||
provisioning = ../asserts-app-plugin/provisioning
|
||||
|
||||
# Directories that are permitted to contain local repositories.
|
||||
# This is a list. Each entry is delimited by a pipe (|). No leading or trailing spaces are supported.
|
||||
@@ -69,7 +70,7 @@ router_logging = false
|
||||
static_root_path = public
|
||||
|
||||
# enable gzip
|
||||
enable_gzip = false
|
||||
enable_gzip = true
|
||||
|
||||
# https certs & key file
|
||||
cert_file =
|
||||
@@ -394,10 +395,10 @@ disable_ip_address_login_protection = true
|
||||
cookie_secure = false
|
||||
|
||||
# set cookie SameSite attribute. defaults to `lax`. can be set to "lax", "strict", "none" and "disabled"
|
||||
cookie_samesite = lax
|
||||
cookie_samesite = disabled
|
||||
|
||||
# set to true if you want to allow browsers to render Grafana in a <frame>, <iframe>, <embed> or <object>. default is false.
|
||||
allow_embedding = false
|
||||
allow_embedding = true
|
||||
|
||||
# Set to true if you want to enable http strict transport security (HSTS) response header.
|
||||
# HSTS tells browsers that the site should only be accessed using HTTPS.
|
||||
@@ -675,7 +676,7 @@ id_response_header_namespaces = user api-key service-account
|
||||
|
||||
# Enables the use of managed service accounts for plugin authentication
|
||||
# This feature currently **only supports single-organization deployments**
|
||||
managed_service_accounts_enabled = false
|
||||
managed_service_accounts_enabled = true
|
||||
|
||||
#################################### Passwordless Auth ###########################
|
||||
[auth.passwordless]
|
||||
@@ -1885,6 +1886,7 @@ renderer_token = -
|
||||
# Concurrent render request limit affects when the /render HTTP endpoint is used. Rendering many images at the same time can overload the server,
|
||||
# which this setting can help protect against by only allowing a certain amount of concurrent requests.
|
||||
concurrent_render_request_limit = 30
|
||||
|
||||
# Determines the lifetime of the render key used by the image renderer to access and render Grafana.
|
||||
# This setting should be expressed as a duration. Examples: 10s (seconds), 5m (minutes), 2h (hours).
|
||||
# Default is 5m. This should be more than enough for most deployments.
|
||||
@@ -1906,7 +1908,7 @@ disable_sanitize_html = false
|
||||
enable_alpha = false
|
||||
app_tls_skip_verify_insecure = false
|
||||
# Enter a comma-separated list of plugin identifiers to identify plugins to load even if they are unsigned. Plugins with modified signatures are never loaded.
|
||||
allow_loading_unsigned_plugins =
|
||||
allow_loading_unsigned_plugins = grafana-asserts-app,grafana-pyroscope-app,grafana-lokiexplore-app,grafana-assistant-app
|
||||
# Enable or disable installing / uninstalling / updating plugins directly from within Grafana.
|
||||
plugin_admin_enabled = true
|
||||
plugin_admin_external_manage_enabled = false
|
||||
@@ -1926,11 +1928,11 @@ disable_plugins =
|
||||
forward_host_env_vars =
|
||||
# Comma separated list of plugin ids to install as part of the startup process.
|
||||
# These will be installed, by default, asynchronously (in the background) while starting Grafana.
|
||||
preinstall =
|
||||
preinstall =
|
||||
# Comma separated list of plugin ids to install before the startup process
|
||||
# These will be installed before starting Grafana. Useful when used with provisioning.
|
||||
preinstall_sync =
|
||||
# Disables preinstall feature. It has the same effect as setting preinstall to an empty list.
|
||||
; preinstall_sync = grafana-assistant-app
|
||||
# Disables preinstall feature. It has the same effect as setting preinstall to an empty list.
|
||||
preinstall_disabled = false
|
||||
# Update strategy for plugins.
|
||||
# Available options: "latest", "minor"
|
||||
@@ -2039,6 +2041,25 @@ grpc_port =
|
||||
license_path =
|
||||
|
||||
[feature_toggles]
|
||||
dockedMegaMenu = true
|
||||
accessControlOnCall = true
|
||||
idForwarding = true
|
||||
externalServiceAccounts = true
|
||||
assertsOnboarding = true
|
||||
assertsOnboardingV2 = true
|
||||
relabelRulesUi = true
|
||||
workbenchAi = true
|
||||
externalEntityRings = true
|
||||
# appO11yInAsserts = true
|
||||
extensionSidebar = true
|
||||
grafanaAssistantLoop = true
|
||||
dashboardNewLayouts = true
|
||||
dashboardScene = true
|
||||
dashboardSceneForViewers = true
|
||||
kubernetesDashboards = true
|
||||
kubernetesDashboardsAPI = true
|
||||
workbenchAiAssistant = true
|
||||
|
||||
# there are currently two ways to enable feature toggles in the `grafana.ini`.
|
||||
# you can either pass an array of feature you want to enable to the `enable` field or
|
||||
# configure each toggle by setting the name of the toggle to true/false. Toggles set to true/false
|
||||
|
||||
@@ -16,11 +16,8 @@ import { KBarSearch } from './KBarSearch';
|
||||
import { ResultItem } from './ResultItem';
|
||||
import { useSearchResults } from './actions/dashboardActions';
|
||||
import { useRegisterRecentScopesActions, useRegisterScopesActions } from './actions/scopeActions';
|
||||
import {
|
||||
useRegisterDynamicActions,
|
||||
useRegisterRecentDashboardsActions,
|
||||
useRegisterStaticActions,
|
||||
} from './actions/useActions';
|
||||
import { useRegisterRecentDashboardsActions, useRegisterStaticActions } from './actions/useActions';
|
||||
import { useDynamicExtensionResults } from './actions/useDynamicExtensionActions';
|
||||
import { CommandPaletteAction } from './types';
|
||||
import { useMatches } from './useMatches';
|
||||
|
||||
@@ -51,12 +48,13 @@ function CommandPaletteContents() {
|
||||
useRegisterRecentDashboardsActions();
|
||||
useRegisterRecentScopesActions();
|
||||
|
||||
// Register dynamic actions from plugins based on search query
|
||||
const { isLoading: isDynamicLoading } = useRegisterDynamicActions(searchQuery);
|
||||
|
||||
const queryToggle = useCallback(() => query.toggle(), [query]);
|
||||
const { scopesRow } = useRegisterScopesActions(searchQuery, queryToggle, currentRootActionId);
|
||||
|
||||
// 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
|
||||
// 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
|
||||
@@ -95,7 +93,11 @@ function CommandPaletteContents() {
|
||||
</div>
|
||||
{scopesRow ? <div className={styles.searchContainer}>{scopesRow}</div> : null}
|
||||
<div className={styles.resultsContainer}>
|
||||
<RenderResults isFetchingSearchResults={isFetchingSearchResults} searchResults={searchResults} />
|
||||
<RenderResults
|
||||
isFetchingSearchResults={isFetchingSearchResults}
|
||||
searchResults={searchResults}
|
||||
dynamicResults={dynamicResults}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</FocusScope>
|
||||
@@ -138,9 +140,10 @@ function AncestorBreadcrumbs() {
|
||||
interface RenderResultsProps {
|
||||
isFetchingSearchResults: boolean;
|
||||
searchResults: CommandPaletteAction[];
|
||||
dynamicResults: Array<{ section: string; items: ActionImpl[] }>;
|
||||
}
|
||||
|
||||
const RenderResults = ({ isFetchingSearchResults, searchResults }: RenderResultsProps) => {
|
||||
const RenderResults = ({ isFetchingSearchResults, searchResults, dynamicResults }: RenderResultsProps) => {
|
||||
const { results: kbarResults, rootActionId } = useMatches();
|
||||
const lateralSpace = getCommandPalettePosition();
|
||||
const styles = useStyles2(getSearchStyles, lateralSpace);
|
||||
@@ -166,6 +169,16 @@ const RenderResults = ({ isFetchingSearchResults, searchResults }: RenderResults
|
||||
|
||||
const items = useMemo(() => {
|
||||
const results = [...kbarResults];
|
||||
|
||||
// Add dynamic results from plugins (already filtered by searchProvider)
|
||||
dynamicResults.forEach(({ section, items }) => {
|
||||
if (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);
|
||||
@@ -175,7 +188,14 @@ const RenderResults = ({ isFetchingSearchResults, searchResults }: RenderResults
|
||||
results.push(...dashboardResultItems);
|
||||
}
|
||||
return results;
|
||||
}, [kbarResults, dashboardsSectionTitle, dashboardResultItems, foldersSectionTitle, folderResultItems]);
|
||||
}, [
|
||||
kbarResults,
|
||||
dynamicResults,
|
||||
dashboardsSectionTitle,
|
||||
dashboardResultItems,
|
||||
foldersSectionTitle,
|
||||
folderResultItems,
|
||||
]);
|
||||
|
||||
const showEmptyState = !isFetchingSearchResults && items.length === 0;
|
||||
useEffect(() => {
|
||||
|
||||
@@ -5,7 +5,6 @@ import { CommandPaletteAction } from '../types';
|
||||
|
||||
import { getRecentDashboardActions } from './dashboardActions';
|
||||
import { useStaticActions } from './staticActions';
|
||||
import { useDynamicExtensionActions } from './useDynamicExtensionActions';
|
||||
import useExtensionActions from './useExtensionActions';
|
||||
|
||||
/**
|
||||
@@ -34,15 +33,3 @@ export function useRegisterRecentDashboardsActions() {
|
||||
|
||||
useRegisterActions(recentDashboardActions, [recentDashboardActions]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register dynamic actions based on search query
|
||||
* These are fetched from plugins that registered dynamic search providers
|
||||
*/
|
||||
export function useRegisterDynamicActions(searchQuery: string) {
|
||||
const { actions: dynamicActions, isLoading } = useDynamicExtensionActions(searchQuery);
|
||||
|
||||
useRegisterActions(dynamicActions, [dynamicActions]);
|
||||
|
||||
return { isLoading };
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ActionImpl } from 'kbar';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useDebounce } from 'react-use';
|
||||
|
||||
@@ -9,8 +10,8 @@ import {
|
||||
import { appEvents } from 'app/core/app_events';
|
||||
import { CloseExtensionSidebarEvent, OpenExtensionSidebarEvent, ToggleExtensionSidebarEvent } from 'app/types/events';
|
||||
|
||||
import { commandPaletteDynamicRegistry } from '../../plugins/extensions/registry/setup';
|
||||
import { createOpenModalFunction } from '../../plugins/extensions/utils';
|
||||
import { commandPaletteDynamicRegistry, CommandPaletteDynamicSearchResult } from '../CommandPaletteDynamicRegistry';
|
||||
import { CommandPaletteAction } from '../types';
|
||||
import { EXTENSIONS_PRIORITY } from '../values';
|
||||
|
||||
@@ -19,8 +20,18 @@ interface DynamicResultWithAction extends CommandPaletteDynamicResult {
|
||||
onSelect?: CommandPaletteDynamicResultAction;
|
||||
}
|
||||
|
||||
export function useDynamicExtensionActions(searchQuery: string): {
|
||||
actions: CommandPaletteAction[];
|
||||
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.
|
||||
*/
|
||||
export function useDynamicExtensionResults(searchQuery: string): {
|
||||
results: DynamicExtensionResultGroup[];
|
||||
isLoading: boolean;
|
||||
} {
|
||||
const [dynamicResults, setDynamicResults] = useState<DynamicResultWithAction[]>([]);
|
||||
@@ -65,15 +76,15 @@ export function useDynamicExtensionActions(searchQuery: string): {
|
||||
// Execute search across all registered providers
|
||||
commandPaletteDynamicRegistry
|
||||
.search(context)
|
||||
.then((resultsMap) => {
|
||||
.then((resultsMap: Map<string, CommandPaletteDynamicSearchResult>) => {
|
||||
if (abortController.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const allResults: DynamicResultWithAction[] = [];
|
||||
|
||||
resultsMap.forEach(({ items, config }) => {
|
||||
items.forEach((item) => {
|
||||
resultsMap.forEach(({ items, config }: CommandPaletteDynamicSearchResult) => {
|
||||
items.forEach((item: CommandPaletteDynamicResult) => {
|
||||
allResults.push({
|
||||
...item,
|
||||
pluginId: config.pluginId,
|
||||
@@ -86,7 +97,7 @@ export function useDynamicExtensionActions(searchQuery: string): {
|
||||
setDynamicResults(allResults);
|
||||
setIsLoading(false);
|
||||
})
|
||||
.catch((error) => {
|
||||
.catch((error: unknown) => {
|
||||
if (!abortController.signal.aborted) {
|
||||
console.error('[CommandPalette] Dynamic search failed:', error);
|
||||
setDynamicResults([]);
|
||||
@@ -100,55 +111,74 @@ export function useDynamicExtensionActions(searchQuery: string): {
|
||||
};
|
||||
}, [debouncedSearchQuery]);
|
||||
|
||||
const actions: CommandPaletteAction[] = useMemo(() => {
|
||||
return dynamicResults.map((result) => ({
|
||||
id: `dynamic-${result.pluginId}-${result.id}`,
|
||||
name: result.title,
|
||||
section: result.section ?? 'Dynamic Results',
|
||||
subtitle: result.description,
|
||||
priority: EXTENSIONS_PRIORITY - 0.5, // Slightly lower than static extensions
|
||||
keywords: result.keywords?.join(' '),
|
||||
perform: () => {
|
||||
if (result.onSelect) {
|
||||
const extensionPointId = 'grafana/commandpalette/action';
|
||||
result.onSelect(result, {
|
||||
context: { searchQuery: debouncedSearchQuery },
|
||||
extensionPointId,
|
||||
openModal: createOpenModalFunction({
|
||||
pluginId: result.pluginId,
|
||||
title: result.title,
|
||||
description: result.description,
|
||||
// Group results by section and convert to ActionImpl objects
|
||||
const results: DynamicExtensionResultGroup[] = useMemo(() => {
|
||||
const groups = new Map<string, CommandPaletteAction[]>();
|
||||
|
||||
dynamicResults.forEach((result) => {
|
||||
const section = result.section ?? 'Dynamic Results';
|
||||
|
||||
if (!groups.has(section)) {
|
||||
groups.set(section, []);
|
||||
}
|
||||
|
||||
const action: CommandPaletteAction = {
|
||||
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 },
|
||||
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,
|
||||
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,
|
||||
};
|
||||
|
||||
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 { actions, isLoading };
|
||||
return { results, isLoading };
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ import { ExtensionRegistriesProvider } from '../extensions/ExtensionRegistriesCo
|
||||
import { AddedComponentsRegistry } from '../extensions/registry/AddedComponentsRegistry';
|
||||
import { AddedFunctionsRegistry } from '../extensions/registry/AddedFunctionsRegistry';
|
||||
import { AddedLinksRegistry } from '../extensions/registry/AddedLinksRegistry';
|
||||
import { CommandPaletteDynamicRegistry } from '../extensions/registry/CommandPaletteDynamicRegistry';
|
||||
import { ExposedComponentsRegistry } from '../extensions/registry/ExposedComponentsRegistry';
|
||||
import { pluginImporter } from '../importer/pluginImporter';
|
||||
import { getPluginSettings } from '../pluginSettings';
|
||||
@@ -94,7 +93,6 @@ function renderUnderRouter(page = '') {
|
||||
exposedComponentsRegistry: new ExposedComponentsRegistry(),
|
||||
addedLinksRegistry: new AddedLinksRegistry(),
|
||||
addedFunctionsRegistry: new AddedFunctionsRegistry(),
|
||||
commandPaletteDynamicRegistry: new CommandPaletteDynamicRegistry(),
|
||||
};
|
||||
const pagePath = page ? `/${page}` : '';
|
||||
const route = {
|
||||
|
||||
@@ -31,7 +31,6 @@ import {
|
||||
useAddedComponentsRegistry,
|
||||
useExposedComponentsRegistry,
|
||||
useAddedFunctionsRegistry,
|
||||
useCommandPaletteDynamicRegistry,
|
||||
} from '../extensions/ExtensionRegistriesContext';
|
||||
import { pluginImporter } from '../importer/pluginImporter';
|
||||
import { getPluginSettings } from '../pluginSettings';
|
||||
@@ -66,7 +65,6 @@ export function AppRootPage({ pluginId, pluginNavSection }: Props) {
|
||||
const addedComponentsRegistry = useAddedComponentsRegistry();
|
||||
const exposedComponentsRegistry = useExposedComponentsRegistry();
|
||||
const addedFunctionsRegistry = useAddedFunctionsRegistry();
|
||||
const commandPaletteDynamicRegistry = useCommandPaletteDynamicRegistry();
|
||||
const location = useLocation();
|
||||
const [state, dispatch] = useReducer(stateSlice.reducer, initialState);
|
||||
const currentUrl = config.appSubUrl + location.pathname + location.search;
|
||||
@@ -126,7 +124,6 @@ export function AppRootPage({ pluginId, pluginNavSection }: Props) {
|
||||
addedComponentsRegistry: addedComponentsRegistry.readOnly(),
|
||||
exposedComponentsRegistry: exposedComponentsRegistry.readOnly(),
|
||||
addedFunctionsRegistry: addedFunctionsRegistry.readOnly(),
|
||||
commandPaletteDynamicRegistry: commandPaletteDynamicRegistry.readOnly(),
|
||||
}}
|
||||
>
|
||||
<plugin.root
|
||||
|
||||
@@ -3,7 +3,6 @@ import { PropsWithChildren, createContext, useContext } from 'react';
|
||||
import { AddedComponentsRegistry } from 'app/features/plugins/extensions/registry/AddedComponentsRegistry';
|
||||
import { AddedFunctionsRegistry } from 'app/features/plugins/extensions/registry/AddedFunctionsRegistry';
|
||||
import { AddedLinksRegistry } from 'app/features/plugins/extensions/registry/AddedLinksRegistry';
|
||||
import { CommandPaletteDynamicRegistry } from 'app/features/plugins/extensions/registry/CommandPaletteDynamicRegistry';
|
||||
import { ExposedComponentsRegistry } from 'app/features/plugins/extensions/registry/ExposedComponentsRegistry';
|
||||
|
||||
import { PluginExtensionRegistries } from './registry/types';
|
||||
@@ -17,7 +16,6 @@ export const AddedLinksRegistryContext = createContext<AddedLinksRegistry | unde
|
||||
export const AddedComponentsRegistryContext = createContext<AddedComponentsRegistry | undefined>(undefined);
|
||||
export const AddedFunctionsRegistryContext = createContext<AddedFunctionsRegistry | undefined>(undefined);
|
||||
export const ExposedComponentsRegistryContext = createContext<ExposedComponentsRegistry | undefined>(undefined);
|
||||
export const CommandPaletteDynamicRegistryContext = createContext<CommandPaletteDynamicRegistry | undefined>(undefined);
|
||||
|
||||
export function useAddedLinksRegistry(): AddedLinksRegistry {
|
||||
const context = useContext(AddedLinksRegistryContext);
|
||||
@@ -51,14 +49,6 @@ export function useExposedComponentsRegistry(): ExposedComponentsRegistry {
|
||||
return context;
|
||||
}
|
||||
|
||||
export function useCommandPaletteDynamicRegistry(): CommandPaletteDynamicRegistry {
|
||||
const context = useContext(CommandPaletteDynamicRegistryContext);
|
||||
if (!context) {
|
||||
throw new Error('No `CommandPaletteDynamicRegistryContext` found.');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export const ExtensionRegistriesProvider = ({
|
||||
registries,
|
||||
children,
|
||||
@@ -68,9 +58,7 @@ export const ExtensionRegistriesProvider = ({
|
||||
<AddedComponentsRegistryContext.Provider value={registries.addedComponentsRegistry}>
|
||||
<AddedFunctionsRegistryContext.Provider value={registries.addedFunctionsRegistry}>
|
||||
<ExposedComponentsRegistryContext.Provider value={registries.exposedComponentsRegistry}>
|
||||
<CommandPaletteDynamicRegistryContext.Provider value={registries.commandPaletteDynamicRegistry}>
|
||||
{children}
|
||||
</CommandPaletteDynamicRegistryContext.Provider>
|
||||
{children}
|
||||
</ExposedComponentsRegistryContext.Provider>
|
||||
</AddedFunctionsRegistryContext.Provider>
|
||||
</AddedComponentsRegistryContext.Provider>
|
||||
|
||||
-922
@@ -1,922 +0,0 @@
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
import { PluginExtensionCommandPaletteContext } from '@grafana/data';
|
||||
|
||||
import { log } from '../logs/log';
|
||||
import { resetLogMock } from '../logs/testUtils';
|
||||
import { isGrafanaDevMode } from '../utils';
|
||||
|
||||
import { CommandPaletteDynamicRegistry } from './CommandPaletteDynamicRegistry';
|
||||
import { MSG_CANNOT_REGISTER_READ_ONLY } from './Registry';
|
||||
|
||||
jest.mock('../utils', () => ({
|
||||
...jest.requireActual('../utils'),
|
||||
isGrafanaDevMode: jest.fn().mockReturnValue(false),
|
||||
}));
|
||||
|
||||
jest.mock('../logs/log', () => {
|
||||
const { createLogMock } = jest.requireActual('../logs/testUtils');
|
||||
const original = jest.requireActual('../logs/log');
|
||||
|
||||
return {
|
||||
...original,
|
||||
log: createLogMock(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('CommandPaletteDynamicRegistry', () => {
|
||||
const pluginId = 'test-plugin';
|
||||
|
||||
beforeEach(() => {
|
||||
resetLogMock(log);
|
||||
jest.mocked(isGrafanaDevMode).mockReturnValue(false);
|
||||
});
|
||||
|
||||
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: [
|
||||
{
|
||||
title: 'Test Provider',
|
||||
searchProvider: mockSearchProvider,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const state = await registry.getState();
|
||||
expect(state).toEqual({
|
||||
[`${pluginId}/Test Provider`]: [
|
||||
{
|
||||
pluginId,
|
||||
config: {
|
||||
title: 'Test Provider',
|
||||
searchProvider: mockSearchProvider,
|
||||
category: pluginId,
|
||||
minQueryLength: 2,
|
||||
debounceMs: 300,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should apply default values for optional config properties', async () => {
|
||||
const registry = new CommandPaletteDynamicRegistry();
|
||||
const mockSearchProvider = jest.fn().mockResolvedValue([]);
|
||||
|
||||
registry.register({
|
||||
pluginId,
|
||||
configs: [
|
||||
{
|
||||
title: 'Test Provider',
|
||||
searchProvider: mockSearchProvider,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const state = await registry.getState();
|
||||
const item = state[`${pluginId}/Test Provider`][0];
|
||||
|
||||
expect(item.config.category).toBe(pluginId);
|
||||
expect(item.config.minQueryLength).toBe(2);
|
||||
expect(item.config.debounceMs).toBe(300);
|
||||
});
|
||||
|
||||
it('should preserve custom config values when provided', async () => {
|
||||
const registry = new CommandPaletteDynamicRegistry();
|
||||
const mockSearchProvider = jest.fn().mockResolvedValue([]);
|
||||
|
||||
registry.register({
|
||||
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];
|
||||
|
||||
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 () => {
|
||||
const registry = new CommandPaletteDynamicRegistry();
|
||||
const mockSearchProvider1 = jest.fn().mockResolvedValue([]);
|
||||
const mockSearchProvider2 = jest.fn().mockResolvedValue([]);
|
||||
|
||||
registry.register({
|
||||
pluginId,
|
||||
configs: [
|
||||
{
|
||||
title: 'Provider 1',
|
||||
searchProvider: mockSearchProvider1,
|
||||
},
|
||||
{
|
||||
title: 'Provider 2',
|
||||
searchProvider: mockSearchProvider2,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const state = await registry.getState();
|
||||
expect(Object.keys(state)).toHaveLength(2);
|
||||
expect(state[`${pluginId}/Provider 1`]).toBeDefined();
|
||||
expect(state[`${pluginId}/Provider 2`]).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: [
|
||||
{
|
||||
title: 'Test Provider',
|
||||
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: [
|
||||
{
|
||||
title: 'Test',
|
||||
searchProvider: jest.fn(),
|
||||
},
|
||||
],
|
||||
});
|
||||
}).toThrow(MSG_CANNOT_REGISTER_READ_ONLY);
|
||||
});
|
||||
|
||||
it('should create a read-only version of the registry', async () => {
|
||||
const registry = new CommandPaletteDynamicRegistry();
|
||||
const readOnlyRegistry = registry.readOnly();
|
||||
|
||||
expect(() => {
|
||||
readOnlyRegistry.register({
|
||||
pluginId,
|
||||
configs: [
|
||||
{
|
||||
title: 'Test',
|
||||
searchProvider: jest.fn(),
|
||||
},
|
||||
],
|
||||
});
|
||||
}).toThrow(MSG_CANNOT_REGISTER_READ_ONLY);
|
||||
|
||||
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: [
|
||||
{
|
||||
title: 'Test Provider',
|
||||
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}/Test Provider`]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Config Validation', () => {
|
||||
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(log.error).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(log.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not register provider without searchProvider', async () => {
|
||||
const registry = new CommandPaletteDynamicRegistry();
|
||||
|
||||
registry.register({
|
||||
pluginId,
|
||||
configs: [
|
||||
{
|
||||
title: 'Test',
|
||||
// @ts-ignore - testing invalid config
|
||||
searchProvider: undefined,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const state = await registry.getState();
|
||||
expect(Object.keys(state)).toHaveLength(0);
|
||||
expect(log.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not register provider with non-function searchProvider', async () => {
|
||||
const registry = new CommandPaletteDynamicRegistry();
|
||||
|
||||
registry.register({
|
||||
pluginId,
|
||||
configs: [
|
||||
{
|
||||
title: 'Test',
|
||||
// @ts-ignore - testing invalid config
|
||||
searchProvider: 'not-a-function',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const state = await registry.getState();
|
||||
expect(Object.keys(state)).toHaveLength(0);
|
||||
expect(log.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should log provider registration in dev mode', async () => {
|
||||
jest.mocked(isGrafanaDevMode).mockReturnValue(true);
|
||||
const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation();
|
||||
|
||||
const registry = new CommandPaletteDynamicRegistry();
|
||||
|
||||
registry.register({
|
||||
pluginId,
|
||||
configs: [
|
||||
{
|
||||
title: 'Test Provider',
|
||||
searchProvider: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await registry.getState();
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Registered provider: test-plugin/Test Provider')
|
||||
);
|
||||
|
||||
consoleLogSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
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: [
|
||||
{
|
||||
title: 'Provider 1',
|
||||
searchProvider: mockSearchProvider1,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
registry.register({
|
||||
pluginId: 'plugin2',
|
||||
configs: [
|
||||
{
|
||||
title: 'Provider 2',
|
||||
searchProvider: mockSearchProvider2,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const context: PluginExtensionCommandPaletteContext = {
|
||||
searchQuery: 'test query',
|
||||
};
|
||||
|
||||
const results = await registry.search(context);
|
||||
|
||||
expect(mockSearchProvider1).toHaveBeenCalledWith(context);
|
||||
expect(mockSearchProvider2).toHaveBeenCalledWith(context);
|
||||
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: [
|
||||
{
|
||||
title: 'Test Provider',
|
||||
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: [
|
||||
{
|
||||
title: 'Test Provider',
|
||||
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: [
|
||||
{
|
||||
title: 'Test Provider',
|
||||
searchProvider: mockSearchProvider,
|
||||
minQueryLength: 2,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const context: PluginExtensionCommandPaletteContext = {
|
||||
searchQuery: 'ab', // Exactly 2 characters
|
||||
};
|
||||
|
||||
await registry.search(context);
|
||||
|
||||
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([
|
||||
{ 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: [
|
||||
{
|
||||
title: 'Test Provider',
|
||||
searchProvider: mockSearchProvider,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const context: PluginExtensionCommandPaletteContext = {
|
||||
searchQuery: 'test',
|
||||
};
|
||||
|
||||
const results = await registry.search(context);
|
||||
const searchResult = results.get(`${pluginId}/Test Provider`);
|
||||
|
||||
expect(searchResult?.items).toHaveLength(5);
|
||||
expect(searchResult?.items[4].id).toBe('5');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Result Validation', () => {
|
||||
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: [
|
||||
{
|
||||
title: 'Test Provider',
|
||||
searchProvider: mockSearchProvider,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const context: PluginExtensionCommandPaletteContext = {
|
||||
searchQuery: 'test',
|
||||
};
|
||||
|
||||
const results = await registry.search(context);
|
||||
const searchResult = results.get(`${pluginId}/Test Provider`);
|
||||
|
||||
expect(searchResult?.items).toHaveLength(1);
|
||||
expect(searchResult?.items[0].id).toBe('valid');
|
||||
expect(log.warning).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: [
|
||||
{
|
||||
title: 'Test Provider',
|
||||
searchProvider: mockSearchProvider,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const context: PluginExtensionCommandPaletteContext = {
|
||||
searchQuery: 'test',
|
||||
};
|
||||
|
||||
const results = await registry.search(context);
|
||||
const searchResult = results.get(`${pluginId}/Test Provider`);
|
||||
|
||||
expect(searchResult?.items).toHaveLength(1);
|
||||
expect(searchResult?.items[0].id).toBe('valid');
|
||||
expect(log.warning).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: [
|
||||
{
|
||||
title: 'Test Provider',
|
||||
searchProvider: mockSearchProvider,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const context: PluginExtensionCommandPaletteContext = {
|
||||
searchQuery: 'test',
|
||||
};
|
||||
|
||||
const results = await registry.search(context);
|
||||
const searchResult = results.get(`${pluginId}/Test Provider`);
|
||||
|
||||
expect(searchResult?.items).toHaveLength(1);
|
||||
expect(searchResult?.items[0].id).toBe('valid');
|
||||
expect(log.warning).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: [
|
||||
{
|
||||
title: 'Test Provider',
|
||||
searchProvider: mockSearchProvider,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const context: PluginExtensionCommandPaletteContext = {
|
||||
searchQuery: 'test',
|
||||
};
|
||||
|
||||
const results = await registry.search(context);
|
||||
const searchResult = results.get(`${pluginId}/Test Provider`);
|
||||
|
||||
expect(searchResult?.items).toHaveLength(1);
|
||||
expect(searchResult?.items[0].id).toBe('valid');
|
||||
expect(log.warning).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: [
|
||||
{
|
||||
title: 'Test Provider',
|
||||
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: [
|
||||
{
|
||||
title: 'Test Provider',
|
||||
searchProvider: mockSearchProvider,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const context: PluginExtensionCommandPaletteContext = {
|
||||
searchQuery: 'test',
|
||||
};
|
||||
|
||||
const results = await registry.search(context);
|
||||
|
||||
expect(results.size).toBe(0);
|
||||
expect(log.warning).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
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: [
|
||||
{
|
||||
title: 'Test Provider',
|
||||
searchProvider: mockSearchProvider,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const context: PluginExtensionCommandPaletteContext = {
|
||||
searchQuery: 'test',
|
||||
};
|
||||
|
||||
const results = await registry.search(context);
|
||||
|
||||
expect(results.size).toBe(0);
|
||||
expect(log.error).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: [
|
||||
{
|
||||
title: 'Test Provider',
|
||||
searchProvider: mockSearchProvider,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const context: PluginExtensionCommandPaletteContext = {
|
||||
searchQuery: 'test',
|
||||
};
|
||||
|
||||
await registry.search(context);
|
||||
|
||||
expect(log.error).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: [
|
||||
{
|
||||
title: 'Failing Provider',
|
||||
searchProvider: failingProvider,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
registry.register({
|
||||
pluginId: 'success-plugin',
|
||||
configs: [
|
||||
{
|
||||
title: 'Success Provider',
|
||||
searchProvider: successProvider,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const context: PluginExtensionCommandPaletteContext = {
|
||||
searchQuery: 'test',
|
||||
};
|
||||
|
||||
const results = await registry.search(context);
|
||||
|
||||
expect(results.size).toBe(1);
|
||||
expect(results.get('success-plugin/Success Provider')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Search Context', () => {
|
||||
it('should handle empty search query', async () => {
|
||||
const registry = new CommandPaletteDynamicRegistry();
|
||||
const mockSearchProvider = jest.fn().mockResolvedValue([]);
|
||||
|
||||
registry.register({
|
||||
pluginId,
|
||||
configs: [
|
||||
{
|
||||
title: 'Test Provider',
|
||||
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: [
|
||||
{
|
||||
title: 'Test Provider',
|
||||
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: [
|
||||
{
|
||||
title: 'Test Provider',
|
||||
searchProvider: mockSearchProvider,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const context: PluginExtensionCommandPaletteContext = {
|
||||
searchQuery: 'test',
|
||||
};
|
||||
|
||||
const results = await registry.search(context);
|
||||
const searchResult = results.get(`${pluginId}/Test Provider`);
|
||||
|
||||
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: [
|
||||
{
|
||||
title: 'Test Provider',
|
||||
searchProvider: mockSearchProvider,
|
||||
category: 'Test Category',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const context: PluginExtensionCommandPaletteContext = {
|
||||
searchQuery: 'test',
|
||||
};
|
||||
|
||||
const results = await registry.search(context);
|
||||
const searchResult = results.get(`${pluginId}/Test Provider`);
|
||||
|
||||
expect(searchResult?.config.pluginId).toBe(pluginId);
|
||||
expect(searchResult?.config.config.title).toBe('Test Provider');
|
||||
expect(searchResult?.config.config.category).toBe('Test Category');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,155 +0,0 @@
|
||||
import { ReplaySubject } from 'rxjs';
|
||||
|
||||
import {
|
||||
PluginExtensionCommandPaletteDynamicConfig,
|
||||
CommandPaletteDynamicResult,
|
||||
PluginExtensionCommandPaletteContext,
|
||||
} from '@grafana/data';
|
||||
|
||||
import { isGrafanaDevMode } from '../utils';
|
||||
|
||||
import { PluginExtensionConfigs, Registry, RegistryType } from './Registry';
|
||||
|
||||
const logPrefix = '[CommandPaletteDynamic]';
|
||||
|
||||
export interface CommandPaletteDynamicRegistryItem {
|
||||
pluginId: string;
|
||||
config: PluginExtensionCommandPaletteDynamicConfig;
|
||||
}
|
||||
|
||||
export interface CommandPaletteDynamicSearchResult {
|
||||
items: CommandPaletteDynamicResult[];
|
||||
config: CommandPaletteDynamicRegistryItem;
|
||||
}
|
||||
|
||||
export class CommandPaletteDynamicRegistry extends Registry<
|
||||
CommandPaletteDynamicRegistryItem[],
|
||||
PluginExtensionCommandPaletteDynamicConfig
|
||||
> {
|
||||
constructor(
|
||||
options: {
|
||||
registrySubject?: ReplaySubject<RegistryType<CommandPaletteDynamicRegistryItem[]>>;
|
||||
initialState?: RegistryType<CommandPaletteDynamicRegistryItem[]>;
|
||||
} = {}
|
||||
) {
|
||||
super(options);
|
||||
}
|
||||
|
||||
mapToRegistry(
|
||||
registry: RegistryType<CommandPaletteDynamicRegistryItem[]>,
|
||||
item: PluginExtensionConfigs<PluginExtensionCommandPaletteDynamicConfig>
|
||||
): RegistryType<CommandPaletteDynamicRegistryItem[]> {
|
||||
const { pluginId, configs } = item;
|
||||
|
||||
for (const config of configs) {
|
||||
const { title, searchProvider, category } = config;
|
||||
|
||||
if (!title || typeof title !== 'string') {
|
||||
this.logger.error(`${logPrefix} Plugin ${pluginId}: title is required and must be a string`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!searchProvider || typeof searchProvider !== 'function') {
|
||||
this.logger.error(`${logPrefix} Plugin ${pluginId}: searchProvider must be a function`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const providerId = `${pluginId}/${title}`;
|
||||
|
||||
if (!(providerId in registry)) {
|
||||
registry[providerId] = [];
|
||||
}
|
||||
|
||||
registry[providerId].push({
|
||||
pluginId,
|
||||
config: {
|
||||
...config,
|
||||
category: category ?? pluginId,
|
||||
minQueryLength: config.minQueryLength ?? 2,
|
||||
debounceMs: config.debounceMs ?? 300,
|
||||
},
|
||||
});
|
||||
|
||||
if (isGrafanaDevMode()) {
|
||||
console.log(`${logPrefix} Registered provider: ${providerId}`);
|
||||
}
|
||||
}
|
||||
|
||||
return registry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a search across all registered providers
|
||||
*/
|
||||
async search(context: PluginExtensionCommandPaletteContext): Promise<Map<string, CommandPaletteDynamicSearchResult>> {
|
||||
const registry = await this.getState();
|
||||
const results = new Map<string, CommandPaletteDynamicSearchResult>();
|
||||
const searchQuery = context.searchQuery ?? '';
|
||||
|
||||
const searchPromises = Object.entries(registry).map(async ([providerId, registryItems]) => {
|
||||
if (!Array.isArray(registryItems) || registryItems.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const item = registryItems[0]; // Take first config per provider
|
||||
const { config } = item;
|
||||
|
||||
// Check minimum query length
|
||||
if (searchQuery.length < (config.minQueryLength ?? 2)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if provider is active
|
||||
if (config.isActive && !config.isActive(context)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const items = await config.searchProvider(context);
|
||||
|
||||
// Validate results
|
||||
if (!Array.isArray(items)) {
|
||||
this.logger.warning(`${logPrefix} Provider ${providerId} did not return an array`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate and filter items
|
||||
const validItems = items
|
||||
.filter((item) => {
|
||||
if (!item.id || typeof item.id !== 'string') {
|
||||
this.logger.warning(`${logPrefix} Provider ${providerId}: result missing id`);
|
||||
return false;
|
||||
}
|
||||
if (!item.title || typeof item.title !== 'string') {
|
||||
this.logger.warning(`${logPrefix} Provider ${providerId}: result missing title`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.slice(0, 5); // Limit to 5 items maximum
|
||||
|
||||
if (validItems.length > 0) {
|
||||
results.set(providerId, { items: validItems, config: item });
|
||||
}
|
||||
} catch (error) {
|
||||
// Don't log AbortErrors as they are expected
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
this.logger.error(`${logPrefix} Search failed for ${providerId}`, { error: String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(searchPromises);
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a read-only version of the registry.
|
||||
*/
|
||||
readOnly() {
|
||||
return new CommandPaletteDynamicRegistry({
|
||||
registrySubject: this.registrySubject,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import { getCoreExtensionConfigurations } from '../getCoreExtensionConfiguration
|
||||
import { AddedComponentsRegistry } from './AddedComponentsRegistry';
|
||||
import { AddedFunctionsRegistry } from './AddedFunctionsRegistry';
|
||||
import { AddedLinksRegistry } from './AddedLinksRegistry';
|
||||
import { CommandPaletteDynamicRegistry } from './CommandPaletteDynamicRegistry';
|
||||
import { ExposedComponentsRegistry } from './ExposedComponentsRegistry';
|
||||
import { PluginExtensionRegistries } from './types';
|
||||
|
||||
@@ -15,13 +14,11 @@ export const addedComponentsRegistry = new AddedComponentsRegistry();
|
||||
export const exposedComponentsRegistry = new ExposedComponentsRegistry();
|
||||
export const addedLinksRegistry = new AddedLinksRegistry();
|
||||
export const addedFunctionsRegistry = new AddedFunctionsRegistry();
|
||||
export const commandPaletteDynamicRegistry = new CommandPaletteDynamicRegistry();
|
||||
export const pluginExtensionRegistries: PluginExtensionRegistries = {
|
||||
addedComponentsRegistry,
|
||||
exposedComponentsRegistry,
|
||||
addedLinksRegistry,
|
||||
addedFunctionsRegistry,
|
||||
commandPaletteDynamicRegistry,
|
||||
};
|
||||
|
||||
// Registering core extension links
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { AddedComponentsRegistry } from './AddedComponentsRegistry';
|
||||
import { AddedFunctionsRegistry } from './AddedFunctionsRegistry';
|
||||
import { AddedLinksRegistry } from './AddedLinksRegistry';
|
||||
import { CommandPaletteDynamicRegistry } from './CommandPaletteDynamicRegistry';
|
||||
import { ExposedComponentsRegistry } from './ExposedComponentsRegistry';
|
||||
|
||||
export type PluginExtensionRegistries = {
|
||||
@@ -9,5 +8,4 @@ export type PluginExtensionRegistries = {
|
||||
exposedComponentsRegistry: ExposedComponentsRegistry;
|
||||
addedFunctionsRegistry: AddedFunctionsRegistry;
|
||||
addedLinksRegistry: AddedLinksRegistry;
|
||||
commandPaletteDynamicRegistry: CommandPaletteDynamicRegistry;
|
||||
};
|
||||
|
||||
@@ -9,7 +9,6 @@ import { resetLogMock } from './logs/testUtils';
|
||||
import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry';
|
||||
import { AddedFunctionsRegistry } from './registry/AddedFunctionsRegistry';
|
||||
import { AddedLinksRegistry } from './registry/AddedLinksRegistry';
|
||||
import { CommandPaletteDynamicRegistry } from './registry/CommandPaletteDynamicRegistry';
|
||||
import { ExposedComponentsRegistry } from './registry/ExposedComponentsRegistry';
|
||||
import { PluginExtensionRegistries } from './registry/types';
|
||||
import { useLoadAppPlugins } from './useLoadAppPlugins';
|
||||
@@ -94,7 +93,6 @@ describe('usePluginComponent()', () => {
|
||||
exposedComponentsRegistry: new ExposedComponentsRegistry(),
|
||||
addedLinksRegistry: new AddedLinksRegistry(),
|
||||
addedFunctionsRegistry: new AddedFunctionsRegistry(),
|
||||
commandPaletteDynamicRegistry: new CommandPaletteDynamicRegistry(),
|
||||
};
|
||||
jest.mocked(useLoadAppPlugins).mockReturnValue({ isLoading: false });
|
||||
jest.mocked(isGrafanaDevMode).mockReturnValue(false);
|
||||
|
||||
@@ -17,7 +17,6 @@ import { resetLogMock } from './logs/testUtils';
|
||||
import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry';
|
||||
import { AddedFunctionsRegistry } from './registry/AddedFunctionsRegistry';
|
||||
import { AddedLinksRegistry } from './registry/AddedLinksRegistry';
|
||||
import { CommandPaletteDynamicRegistry } from './registry/CommandPaletteDynamicRegistry';
|
||||
import { ExposedComponentsRegistry } from './registry/ExposedComponentsRegistry';
|
||||
import { PluginExtensionRegistries } from './registry/types';
|
||||
import { useLoadAppPlugins } from './useLoadAppPlugins';
|
||||
@@ -75,7 +74,6 @@ describe('usePluginComponents()', () => {
|
||||
exposedComponentsRegistry: new ExposedComponentsRegistry(),
|
||||
addedLinksRegistry: new AddedLinksRegistry(),
|
||||
addedFunctionsRegistry: new AddedFunctionsRegistry(),
|
||||
commandPaletteDynamicRegistry: new CommandPaletteDynamicRegistry(),
|
||||
};
|
||||
|
||||
pluginMeta = {
|
||||
|
||||
@@ -16,7 +16,6 @@ import { resetLogMock } from './logs/testUtils';
|
||||
import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry';
|
||||
import { AddedFunctionsRegistry } from './registry/AddedFunctionsRegistry';
|
||||
import { AddedLinksRegistry } from './registry/AddedLinksRegistry';
|
||||
import { CommandPaletteDynamicRegistry } from './registry/CommandPaletteDynamicRegistry';
|
||||
import { ExposedComponentsRegistry } from './registry/ExposedComponentsRegistry';
|
||||
import { PluginExtensionRegistries } from './registry/types';
|
||||
import { useLoadAppPlugins } from './useLoadAppPlugins';
|
||||
@@ -68,7 +67,6 @@ describe('usePluginFunctions()', () => {
|
||||
exposedComponentsRegistry: new ExposedComponentsRegistry(),
|
||||
addedLinksRegistry: new AddedLinksRegistry(),
|
||||
addedFunctionsRegistry: new AddedFunctionsRegistry(),
|
||||
commandPaletteDynamicRegistry: new CommandPaletteDynamicRegistry(),
|
||||
};
|
||||
resetLogMock(log);
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ import { resetLogMock } from './logs/testUtils';
|
||||
import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry';
|
||||
import { AddedFunctionsRegistry } from './registry/AddedFunctionsRegistry';
|
||||
import { AddedLinksRegistry } from './registry/AddedLinksRegistry';
|
||||
import { CommandPaletteDynamicRegistry } from './registry/CommandPaletteDynamicRegistry';
|
||||
import { ExposedComponentsRegistry } from './registry/ExposedComponentsRegistry';
|
||||
import { PluginExtensionRegistries } from './registry/types';
|
||||
import { useLoadAppPlugins } from './useLoadAppPlugins';
|
||||
@@ -68,7 +67,6 @@ describe('usePluginLinks()', () => {
|
||||
exposedComponentsRegistry: new ExposedComponentsRegistry(),
|
||||
addedLinksRegistry: new AddedLinksRegistry(),
|
||||
addedFunctionsRegistry: new AddedFunctionsRegistry(),
|
||||
commandPaletteDynamicRegistry: new CommandPaletteDynamicRegistry(),
|
||||
};
|
||||
resetLogMock(log);
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
throwIfAngular,
|
||||
} from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { commandPaletteDynamicRegistry } from 'app/features/commandPalette/CommandPaletteDynamicRegistry';
|
||||
import { GenericDataSourcePlugin } from 'app/features/datasources/types';
|
||||
import { getPanelPluginLoadError } from 'app/features/panel/components/PanelPluginError';
|
||||
|
||||
@@ -20,7 +21,6 @@ import {
|
||||
addedComponentsRegistry,
|
||||
addedFunctionsRegistry,
|
||||
addedLinksRegistry,
|
||||
commandPaletteDynamicRegistry,
|
||||
exposedComponentsRegistry,
|
||||
} from '../extensions/registry/setup';
|
||||
import { pluginsLogger } from '../utils';
|
||||
|
||||
Reference in New Issue
Block a user