-
+ return (
+
+
+
+
+
+
+
+
+
+ {isFetchingSearchResults && }
-
-
-
-
- ) : null;
+ {scopesRow ?
{scopesRow}
: null}
+
+
+
+
+
+
+
+ );
+}
+
+/**
+ * Breadcrumbs for selected actions or categories in the command palette. This has to be a separate component
+ * from the one that is registering actions because we need actions prop from kbar and doing both in the same component
+ * creates rerender loop.
+ * @constructor
+ */
+function AncestorBreadcrumbs() {
+ const lateralSpace = getCommandPalettePosition();
+ const styles = useStyles2(getSearchStyles, lateralSpace);
+
+ const { actions, currentRootActionId } = useKBar((state) => ({
+ actions: state.actions,
+ currentRootActionId: state.currentRootActionId,
+ }));
+
+ // To show breadcrumbs of actions selected if they are nested
+ const ancestorActions = currentRootActionId
+ ? [...actions[currentRootActionId].ancestors, actions[currentRootActionId]]
+ : [];
+
+ return (
+ ancestorActions.length > 0 && (
+
+ {ancestorActions.map((action, index) => (
+ {action.name} /
+ ))}
+
+ )
+ );
}
interface RenderResultsProps {
@@ -204,9 +252,9 @@ const getSearchStyles = (theme: GrafanaTheme2, lateralSpace: number) => {
background: theme.components.input.background,
borderBottom: `1px solid ${theme.colors.border.weak}`,
display: 'flex',
- gap: theme.spacing(1),
padding: theme.spacing(1, 2),
position: 'relative',
+ justifyContent: 'space-between',
}),
search: css({
fontSize: theme.typography.fontSize,
@@ -235,5 +283,40 @@ const getSearchStyles = (theme: GrafanaTheme2, lateralSpace: number) => {
borderTop: 'none',
marginTop: 0,
}),
+ breadcrumbs: css({
+ label: 'breadcrumbs',
+ fontSize: theme.typography.body.fontSize,
+ fontWeight: theme.typography.fontWeightMedium,
+ lineHeight: theme.typography.body.lineHeight,
+ color: theme.colors.text.primary,
+ display: 'flex',
+ alignItems: 'center',
+ whiteSpace: 'nowrap',
+ }),
+ scopesText: css({
+ label: 'scopesText',
+ fontSize: theme.typography.bodySmall.fontSize,
+ fontWeight: theme.typography.fontWeightMedium,
+ lineHeight: theme.typography.bodySmall.lineHeight,
+ color: theme.colors.text.secondary,
+ }),
+ searchIcon: css({
+ marginRight: theme.spacing(1),
+ }),
+ selectedScope: css({
+ background: theme.colors.background.secondary,
+ borderRadius: theme.shape.radius.default,
+ padding: theme.spacing(0, 0.5),
+ fontSize: theme.typography.bodySmall.fontSize,
+ fontWeight: theme.typography.fontWeightMedium,
+ lineHeight: theme.typography.bodySmall.lineHeight,
+ color: theme.colors.text.secondary,
+ display: 'inline-flex',
+ alignItems: 'center',
+ position: 'relative',
+ border: `1px solid ${theme.colors.background.secondary}`,
+ whiteSpace: 'nowrap',
+ marginRight: theme.spacing(0.5),
+ }),
};
};
diff --git a/public/app/features/commandPalette/KBarResults.tsx b/public/app/features/commandPalette/KBarResults.tsx
index af01165f0b4..2eb8a5aecf7 100644
--- a/public/app/features/commandPalette/KBarResults.tsx
+++ b/public/app/features/commandPalette/KBarResults.tsx
@@ -71,7 +71,7 @@ export const KBarResults = (props: KBarResultsProps) => {
}
return nextIndex;
});
- } else if (event.key === 'Enter') {
+ } else if (event.key === 'Enter' && !event.metaKey) {
event.preventDefault();
// storing the active dom element in a ref prevents us from
// having to calculate the current action to perform based
@@ -122,7 +122,10 @@ export const KBarResults = (props: KBarResultsProps) => {
if (item.command) {
item.command.perform(item);
- query.toggle();
+ // TODO: ideally the perform method would return some marker or we would have something like preventDefault()
+ if (!item.id.startsWith('scopes/') || item.id === 'scopes/apply') {
+ query.toggle();
+ }
} else if (url) {
if (!(ev.ctrlKey || ev.metaKey || ev.shiftKey)) {
query.toggle();
diff --git a/public/app/features/commandPalette/KBarSearch.tsx b/public/app/features/commandPalette/KBarSearch.tsx
new file mode 100644
index 00000000000..373be079184
--- /dev/null
+++ b/public/app/features/commandPalette/KBarSearch.tsx
@@ -0,0 +1,86 @@
+import { css } from '@emotion/css';
+import { useKBar, VisualState } from 'kbar';
+import * as React from 'react';
+
+import { GrafanaTheme2 } from '@grafana/data';
+import { useStyles2 } from '@grafana/ui';
+
+export const KBAR_LISTBOX = 'kbar-listbox';
+export const getListboxItemId = (id: number) => `kbar-listbox-item-${id}`;
+
+export function KBarSearch(
+ props: React.InputHTMLAttributes
& {
+ defaultPlaceholder?: string;
+ }
+) {
+ const { query, search, actions, currentRootActionId, activeIndex, showing, options } = useKBar((state) => ({
+ search: state.searchQuery,
+ currentRootActionId: state.currentRootActionId,
+ actions: state.actions,
+ activeIndex: state.activeIndex,
+ showing: state.visualState === VisualState.showing,
+ }));
+
+ const [inputValue, setInputValue] = React.useState(search);
+ React.useEffect(() => {
+ query.setSearch(inputValue);
+ }, [inputValue, query]);
+
+ const { defaultPlaceholder, ...rest } = props;
+
+ React.useEffect(() => {
+ query.setSearch('');
+ query.getInput().focus();
+ setInputValue('');
+ return () => query.setSearch('');
+ }, [currentRootActionId, query]);
+
+ const defaultText = defaultPlaceholder ?? 'Type a command or search…';
+
+ const styles = useStyles2(getStyles);
+
+ return (
+ {
+ props.onChange?.(event);
+ setInputValue(event.target.value);
+ options?.callbacks?.onQueryChange?.(event.target.value);
+ }}
+ onKeyDown={(event) => {
+ props.onKeyDown?.(event);
+ if (currentRootActionId && !search && event.key === 'Backspace') {
+ const parent = actions[currentRootActionId].parent;
+ query.setCurrentRootAction(parent);
+ }
+ }}
+ />
+ );
+}
+
+const getStyles = (theme: GrafanaTheme2) => {
+ return {
+ input: css({
+ label: 'kbar-search-input',
+ fontSize: theme.typography.body.fontSize,
+ fontWeight: theme.typography.fontWeightMedium,
+ lineHeight: theme.typography.body.lineHeight,
+ color: theme.colors.text.secondary,
+ width: '100%',
+ outline: 'none',
+ paddingLeft: 0,
+ }),
+ };
+};
diff --git a/public/app/features/commandPalette/ScopesRow.tsx b/public/app/features/commandPalette/ScopesRow.tsx
new file mode 100644
index 00000000000..51d8127e394
--- /dev/null
+++ b/public/app/features/commandPalette/ScopesRow.tsx
@@ -0,0 +1,82 @@
+import { css } from '@emotion/css';
+
+import { GrafanaTheme2 } from '@grafana/data';
+import { Button, FilterPill, Stack, Text, useStyles2 } from '@grafana/ui';
+
+import { Trans } from '../../core/internationalization';
+import { getModKey } from '../../core/utils/browser';
+import { ToggleNode, TreeScope } from '../scopes/selector/types';
+
+type Props = {
+ treeScopes: TreeScope[];
+ isDirty: boolean;
+ apply: () => void;
+ toggleNode: (node: ToggleNode) => void;
+};
+
+/**
+ * Shows scopes that are already selected and applied or the ones user just selected in the palette, with an apply
+ * button if the selection is dirty.
+ */
+export function ScopesRow({ treeScopes, isDirty, apply, toggleNode }: Props) {
+ const styles = useStyles2(getStyles);
+ return (
+ <>
+
+
+ Scopes:
+
+ {treeScopes?.map((scope) => {
+ return (
+ {
+ toggleNode(scope);
+ }}
+ />
+ );
+ })}
+
+ {isDirty && (
+
+ )}
+ >
+ );
+}
+
+const getStyles = (theme: GrafanaTheme2) => {
+ return {
+ scopesText: css({
+ label: 'scopesText',
+ fontSize: theme.typography.bodySmall.fontSize,
+ fontWeight: theme.typography.fontWeightMedium,
+ lineHeight: theme.typography.bodySmall.lineHeight,
+ color: theme.colors.text.secondary,
+ }),
+ selectedScope: css({
+ background: theme.colors.background.secondary,
+ borderRadius: theme.shape.radius.default,
+ padding: theme.spacing(0, 0.5),
+ fontSize: theme.typography.bodySmall.fontSize,
+ fontWeight: theme.typography.fontWeightMedium,
+ lineHeight: theme.typography.bodySmall.lineHeight,
+ color: theme.colors.text.secondary,
+ display: 'inline-flex',
+ alignItems: 'center',
+ position: 'relative',
+ border: `1px solid ${theme.colors.background.secondary}`,
+ whiteSpace: 'nowrap',
+ marginRight: theme.spacing(0.5),
+ }),
+ };
+};
diff --git a/public/app/features/commandPalette/actions/dashboardActions.ts b/public/app/features/commandPalette/actions/dashboardActions.ts
index 5b9f01c6e71..9c18f706b17 100644
--- a/public/app/features/commandPalette/actions/dashboardActions.ts
+++ b/public/app/features/commandPalette/actions/dashboardActions.ts
@@ -79,6 +79,11 @@ export async function getSearchResultActions(searchQuery: string): Promise([]);
const [isFetchingSearchResults, setIsFetchingSearchResults] = useState(false);
diff --git a/public/app/features/commandPalette/actions/recentScopesActions.ts b/public/app/features/commandPalette/actions/recentScopesActions.ts
index 2cac2372522..099ccee95bd 100644
--- a/public/app/features/commandPalette/actions/recentScopesActions.ts
+++ b/public/app/features/commandPalette/actions/recentScopesActions.ts
@@ -1,17 +1,18 @@
import { config } from '@grafana/runtime';
import { t } from 'app/core/internationalization';
-import { defaultScopesServices } from 'app/features/scopes/ScopesContextProvider';
+import { useScopesServices } from 'app/features/scopes/ScopesContextProvider';
import { CommandPaletteAction } from '../types';
import { RECENT_SCOPES_PRIORITY } from '../values';
export function getRecentScopesActions(): CommandPaletteAction[] {
- if (!config.featureToggles.scopeFilters) {
+ const services = useScopesServices();
+
+ if (!(config.featureToggles.scopeFilters && services)) {
return [];
}
- const { scopesSelectorService } = defaultScopesServices();
-
+ const { scopesSelectorService } = services;
const recentScopes = scopesSelectorService.getRecentScopes();
return recentScopes.map((recentScope) => {
diff --git a/public/app/features/commandPalette/actions/staticActions.ts b/public/app/features/commandPalette/actions/staticActions.ts
index 5dd1415a822..d1ff72fd81d 100644
--- a/public/app/features/commandPalette/actions/staticActions.ts
+++ b/public/app/features/commandPalette/actions/staticActions.ts
@@ -1,9 +1,12 @@
+import { useMemo } from 'react';
+
import { NavModelItem } from '@grafana/data';
import { enrichHelpItem } from 'app/core/components/AppChrome/MegaMenu/utils';
import { performInviteUserClick, shouldRenderInviteUserButton } from 'app/core/components/InviteUserButton/utils';
import { t } from 'app/core/internationalization';
import { changeTheme } from 'app/core/services/theme';
+import { useSelector } from '../../../types';
import { CommandPaletteAction } from '../types';
import { ACTIONS_PRIORITY, DEFAULT_PRIORITY, PREFERENCES_PRIORITY } from '../values';
@@ -71,11 +74,11 @@ function navTreeToActions(navTree: NavModelItem[], parents: NavModelItem[] = [])
return navActions;
}
-export default (navBarTree: NavModelItem[], extensionActions: CommandPaletteAction[]): CommandPaletteAction[] => {
- const globalActions: CommandPaletteAction[] = [
+function getGlobalActions(): CommandPaletteAction[] {
+ return [
{
id: 'preferences/theme',
- name: t('command-palette.action.change-theme', 'Change theme...'),
+ name: t('command-palette.action.change-theme', 'Change theme'),
keywords: 'interface color dark light',
section: t('command-palette.section.preferences', 'Preferences'),
priority: PREFERENCES_PRIORITY,
@@ -97,20 +100,24 @@ export default (navBarTree: NavModelItem[], extensionActions: CommandPaletteActi
priority: PREFERENCES_PRIORITY,
},
];
+}
- const navBarActions = navTreeToActions(navBarTree);
+export function useStaticActions(): CommandPaletteAction[] {
+ const navBarTree = useSelector((state) => state.navBarTree);
+ return useMemo(() => {
+ const navBarActions = navTreeToActions(navBarTree);
- if (shouldRenderInviteUserButton) {
- navBarActions.push({
- id: 'invite-user',
- name: t('navigation.invite-user.invite-new-member-button', 'Invite new member'),
- section: t('command-palette.section.actions', 'Actions'),
- priority: ACTIONS_PRIORITY,
- perform: () => {
- performInviteUserClick('command_palette_actions', 'invite-user-command-palette');
- },
- });
- }
-
- return [...globalActions, ...extensionActions, ...navBarActions];
-};
+ if (shouldRenderInviteUserButton) {
+ navBarActions.push({
+ id: 'invite-user',
+ name: t('navigation.invite-user.invite-new-member-button', 'Invite new member'),
+ section: t('command-palette.section.actions', 'Actions'),
+ priority: ACTIONS_PRIORITY,
+ perform: () => {
+ performInviteUserClick('command_palette_actions', 'invite-user-command-palette');
+ },
+ });
+ }
+ return [...getGlobalActions(), ...navBarActions];
+ }, [navBarTree]);
+}
diff --git a/public/app/features/commandPalette/actions/useActions.ts b/public/app/features/commandPalette/actions/useActions.ts
deleted file mode 100644
index 5de66ff7375..00000000000
--- a/public/app/features/commandPalette/actions/useActions.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-import { useEffect, useState } from 'react';
-
-import { useSelector } from 'app/types';
-
-import { CommandPaletteAction } from '../types';
-
-import { getRecentDashboardActions } from './dashboardActions';
-import { getRecentScopesActions } from './recentScopesActions';
-import getStaticActions from './staticActions';
-import useExtensionActions from './useExtensionActions';
-
-export default function useActions(searchQuery: string) {
- const [navTreeActions, setNavTreeActions] = useState([]);
- const [recentDashboardActions, setRecentDashboardActions] = useState([]);
- const extensionActions = useExtensionActions();
-
- const navBarTree = useSelector((state) => state.navBarTree);
- const recentScopesActions = getRecentScopesActions();
-
- // Load standard static actions
- useEffect(() => {
- const staticActionsResp = getStaticActions(navBarTree, extensionActions);
- setNavTreeActions(staticActionsResp);
- }, [navBarTree, extensionActions]);
-
- // Load recent dashboards - we don't want them to reload when the nav tree changes
- useEffect(() => {
- if (!searchQuery) {
- getRecentDashboardActions()
- .then((recentDashboardActions) => setRecentDashboardActions(recentDashboardActions))
- .catch((err) => {
- console.error('Error loading recent dashboard actions', err);
- });
- }
- }, [searchQuery]);
-
- return searchQuery ? navTreeActions : [...recentDashboardActions, ...navTreeActions, ...recentScopesActions];
-}
diff --git a/public/app/features/commandPalette/actions/useActions.tsx b/public/app/features/commandPalette/actions/useActions.tsx
new file mode 100644
index 00000000000..3e4225b76c9
--- /dev/null
+++ b/public/app/features/commandPalette/actions/useActions.tsx
@@ -0,0 +1,194 @@
+import { useRegisterActions } from 'kbar';
+import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
+import { useObservable } from 'react-use';
+import { Observable } from 'rxjs';
+
+import { config } from '@grafana/runtime';
+
+import { t } from '../../../core/internationalization';
+import { useScopesServices } from '../../scopes/ScopesContextProvider';
+import { ScopesSelectorServiceState } from '../../scopes/selector/ScopesSelectorService';
+import { NodesMap, Node, TreeScope, ToggleNode } from '../../scopes/selector/types';
+import { ScopesRow } from '../ScopesRow';
+import { CommandPaletteAction } from '../types';
+import { SCOPES_PRIORITY } from '../values';
+
+import { getRecentDashboardActions } from './dashboardActions';
+import { getRecentScopesActions } from './recentScopesActions';
+import { useStaticActions } from './staticActions';
+import useExtensionActions from './useExtensionActions';
+
+/**
+ * Register navigation actions to different parts of grafana or some preferences stuff like themes.
+ */
+export function useRegisterStaticActions() {
+ const extensionActions = useExtensionActions();
+ const staticActions = useStaticActions();
+
+ const navTreeActions = useMemo(() => {
+ return [...staticActions, ...extensionActions];
+ }, [staticActions, extensionActions]);
+
+ useRegisterActions(navTreeActions, [navTreeActions]);
+}
+
+export function useRegisterRecentDashboardsActions(searchQuery: string) {
+ const [recentDashboardActions, setRecentDashboardActions] = useState([]);
+ useEffect(() => {
+ if (!searchQuery) {
+ getRecentDashboardActions()
+ .then((recentDashboardActions) => setRecentDashboardActions(recentDashboardActions))
+ .catch((err) => {
+ console.error('Error loading recent dashboard actions', err);
+ });
+ }
+ }, [searchQuery]);
+
+ useRegisterActions(recentDashboardActions, [recentDashboardActions]);
+}
+
+export function useRegisterRecentScopesActions() {
+ const recentScopesActions = getRecentScopesActions();
+ useRegisterActions(recentScopesActions, [recentScopesActions]);
+}
+
+/**
+ * Special actions for scopes. Scopes are already hierarchical and loaded dynamically so we create actions based on
+ * them as we load them. This also returns an additional component to be shown with selected actions and a button to
+ * apply the selection.
+ * @param searchQuery
+ * @param onApply
+ * @param parentId
+ */
+export function useRegisterScopesActions(
+ searchQuery: string,
+ onApply: () => void,
+ parentId?: string | null
+): { scopesRow?: ReactNode } {
+ const services = useScopesServices();
+
+ // Conditional hooks, but this should only change if feature toggles changes so not in runtime.
+ if (!(config.featureToggles.scopeFilters && services)) {
+ return { scopesRow: undefined };
+ }
+
+ const { updateNode, toggleNodeSelect, apply, resetSelection } = services.scopesSelectorService;
+
+ // Initialize the scopes first time this runs and reset the scopes that were selected on unmount.
+ useEffect(() => {
+ updateNode([''], true, '');
+ return () => {
+ resetSelection();
+ };
+ }, [updateNode, resetSelection]);
+
+ // Load next level of scopes when the parentId changes.
+ useEffect(() => {
+ updateNode(getScopePathFromActionId(parentId), true, searchQuery);
+ }, [updateNode, searchQuery, parentId]);
+
+ const selectorServiceState: ScopesSelectorServiceState | undefined = useObservable(
+ services.scopesSelectorService.stateObservable ?? new Observable(),
+ services.scopesSelectorService.state
+ );
+
+ const { nodes, loading, loadingNodeName, treeScopes, selectedScopes } = selectorServiceState;
+ const nodesActions = mapScopeNodesToActions(nodes, treeScopes, toggleNodeSelect);
+
+ // Other types can use the actions themselves as a dependency to prevent registering every time the hook runs. The
+ // scopes tree though is loaded on demand, and it would be a deep check to see if something changes these deps are
+ // approximation of when the actions really change.
+ useRegisterActions(nodesActions, [parentId, loading, loadingNodeName, treeScopes]);
+
+ const isDirty =
+ treeScopes
+ .map((t) => t.scopeName)
+ .sort()
+ .join('') !==
+ selectedScopes
+ .map((s) => s.scope.metadata.name)
+ .sort()
+ .join('');
+
+ const finalApply = useCallback(() => {
+ apply();
+ onApply();
+ }, [apply, onApply]);
+
+ // Add keyboard shortcut to apply the selection.
+ useEffect(() => {
+ function handler(event: KeyboardEvent) {
+ if (isDirty && event.key === 'Enter' && event.metaKey) {
+ event.preventDefault();
+ finalApply();
+ }
+ }
+ window.addEventListener('keydown', handler);
+ return () => window.removeEventListener('keydown', handler);
+ }, [isDirty, finalApply]);
+
+ return {
+ scopesRow:
+ isDirty || treeScopes?.length ? (
+
+ ) : null,
+ };
+}
+
+function mapScopeNodesToActions(
+ nodes: NodesMap,
+ selectedScopes: TreeScope[],
+ toggleNodeSelect: (node: ToggleNode) => void
+) {
+ const actions: CommandPaletteAction[] = [
+ {
+ id: 'scopes',
+ section: t('command-palette.action.scopes', 'Scopes'),
+ name: t('command-palette.action.scopes', 'Scopes'),
+ keywords: 'scopes filters',
+ priority: SCOPES_PRIORITY,
+ },
+ ];
+
+ const traverse = (node: Node, parentId: string) => {
+ // TODO: not sure how and why a node.nodes can be undefined
+ if (!node.nodes || Object.keys(node.nodes).length === 0) {
+ return;
+ }
+ for (const key of Object.keys(node.nodes)) {
+ const child = node.nodes[key];
+
+ // Selected scopes are not shown in the list but in separate section
+ if (child.nodeType === 'leaf') {
+ if (selectedScopes.map((s) => s.scopeName).includes(child.linkId!)) {
+ continue;
+ }
+ }
+
+ const action: CommandPaletteAction = {
+ id: `${parentId}/${child.name}`,
+ name: child.title,
+ keywords: `${child.title} ${child.name}`,
+ priority: SCOPES_PRIORITY,
+ parent: parentId,
+ };
+
+ if (child.nodeType === 'leaf') {
+ action.perform = () => {
+ toggleNodeSelect({ scopeName: child.name, path: getScopePathFromActionId(action.id) });
+ };
+ }
+
+ actions.push(action);
+ traverse(child, action.id);
+ }
+ };
+
+ traverse(nodes[''], 'scopes');
+ return actions;
+}
+
+function getScopePathFromActionId(id?: string | null) {
+ // The root action has id scopes while in the selectorService tree the root id = ''
+ return id?.replace('scopes', '').split('/') ?? [''];
+}
diff --git a/public/app/features/commandPalette/values.ts b/public/app/features/commandPalette/values.ts
index a3bc8b24ddf..2fc599254b0 100644
--- a/public/app/features/commandPalette/values.ts
+++ b/public/app/features/commandPalette/values.ts
@@ -1,3 +1,4 @@
+export const SCOPES_PRIORITY = 8;
export const RECENT_SCOPES_PRIORITY = 7;
export const RECENT_DASHBOARDS_PRIORITY = 6;
export const ACTIONS_PRIORITY = 5;
diff --git a/public/app/features/scopes/selector/ScopesSelectorService.test.ts b/public/app/features/scopes/selector/ScopesSelectorService.test.ts
new file mode 100644
index 00000000000..a511ec86831
--- /dev/null
+++ b/public/app/features/scopes/selector/ScopesSelectorService.test.ts
@@ -0,0 +1,266 @@
+import { Scope } from '@grafana/data';
+
+import { ScopesApiClient } from '../ScopesApiClient';
+import { ScopesDashboardsService } from '../dashboards/ScopesDashboardsService';
+
+import { ScopesSelectorService } from './ScopesSelectorService';
+import { Node, NodeReason, NodesMap } from './types';
+
+describe('ScopesSelectorService', () => {
+ let service: ScopesSelectorService;
+ let apiClient: jest.Mocked;
+ let dashboardsService: jest.Mocked;
+
+ const mockScope: Scope = {
+ metadata: {
+ name: 'test-scope',
+ },
+ spec: {
+ title: 'test-scope',
+ type: 'scope',
+ description: 'test scope',
+ category: 'scope',
+ filters: [],
+ },
+ };
+
+ const mockNode: Node = {
+ name: 'test-scope',
+ title: 'Test Node',
+ reason: NodeReason.Result,
+ nodeType: 'container',
+ expandable: true,
+ selectable: false,
+ expanded: false,
+ query: '',
+ nodes: {},
+ };
+
+ const mockNodesMap: NodesMap = {
+ '': mockNode,
+ };
+
+ beforeEach(() => {
+ apiClient = {
+ fetchScope: jest.fn().mockResolvedValue(mockScope),
+ fetchMultipleScopes: jest.fn().mockResolvedValue([{ scope: mockScope, path: ['', 'test-scope'] }]),
+ fetchNode: jest.fn().mockResolvedValue(mockNodesMap),
+ fetchDashboards: jest.fn().mockResolvedValue([]),
+ fetchScopeNavigations: jest.fn().mockResolvedValue([]),
+ } as unknown as jest.Mocked;
+
+ dashboardsService = {
+ fetchDashboards: jest.fn(),
+ } as unknown as jest.Mocked;
+
+ service = new ScopesSelectorService(apiClient, dashboardsService);
+ });
+
+ describe('updateNode', () => {
+ it('should update node and fetch children when expanded', async () => {
+ await service.updateNode([''], true, '');
+
+ expect(apiClient.fetchNode).toHaveBeenCalledWith('', '');
+ expect(service.state.nodes[''].expanded).toBe(true);
+ });
+
+ it('should update node query and fetch children when query changes', async () => {
+ await service.updateNode([''], false, 'new-query');
+
+ expect(apiClient.fetchNode).toHaveBeenCalledWith('', 'new-query');
+ });
+
+ it('should not fetch children when node is collapsed and query is unchanged', async () => {
+ // First expand the node
+ await service.updateNode([''], true, '');
+
+ // Then collapse it
+ await service.updateNode([''], false, '');
+
+ // fetchNode should be called only once (for the expansion)
+ expect(apiClient.fetchNode).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('toggleNodeSelect', () => {
+ it('should select a node when it is not selected', async () => {
+ await service.updateNode([''], true, '');
+
+ const rootNode = service.state.nodes[''];
+ rootNode.nodes['test-scope'] = {
+ ...mockNode,
+ selectable: true,
+ linkId: 'test-scope',
+ };
+
+ service.toggleNodeSelect({ path: ['', 'test-scope'] });
+
+ expect(service.state.treeScopes).toEqual([
+ {
+ scopeName: 'test-scope',
+ path: ['', 'test-scope'],
+ title: 'Test Node',
+ },
+ ]);
+ expect(apiClient.fetchScope).toHaveBeenCalledWith('test-scope');
+ });
+
+ it('should deselect a node when it is already selected', async () => {
+ await service.updateNode([''], true, '');
+
+ const rootNode = service.state.nodes[''];
+ rootNode.nodes['test-scope'] = {
+ ...mockNode,
+ selectable: true,
+ linkId: 'test-scope',
+ };
+
+ // Select the node
+ service.toggleNodeSelect({ path: ['', 'test-scope'] });
+
+ // Deselect the node
+ service.toggleNodeSelect({ path: ['', 'test-scope'] });
+
+ expect(service.state.treeScopes).toEqual([]);
+ });
+
+ it('should deselect a node by name', async () => {
+ // Make the scope selected and applied
+ await service.changeScopes(['test-scope']);
+
+ // Deselect the node
+ service.toggleNodeSelect({ scopeName: 'test-scope' });
+
+ expect(service.state.treeScopes).toEqual([]);
+ });
+ });
+
+ describe('changeScopes', () => {
+ it('should update treeScopes with the provided scope names', () => {
+ service.changeScopes(['test-scope']);
+
+ expect(service.state.treeScopes).toEqual([
+ {
+ scopeName: 'test-scope',
+ path: [],
+ title: 'test-scope',
+ },
+ ]);
+ });
+ });
+
+ describe('open', () => {
+ it('should open the selector and load root nodes if not loaded', async () => {
+ await service.open();
+
+ expect(service.state.opened).toBe(true);
+ });
+
+ it('should not reload root nodes if already loaded', async () => {
+ // First load the nodes
+ await service.updateNode([''], true, '');
+
+ // Reset the mock to check if it's called again
+ apiClient.fetchNode.mockClear();
+
+ // Open the selector
+ await service.open();
+
+ expect(service.state.opened).toBe(true);
+ });
+ });
+
+ describe('closeAndReset', () => {
+ it('should close the selector and reset treeScopes to match selectedScopes', async () => {
+ // Setup: Open the selector and select a scope
+ await service.open();
+
+ await service.changeScopes(['test-scope']);
+
+ service.closeAndReset();
+
+ expect(service.state.opened).toBe(false);
+ expect(service.state.treeScopes).toEqual([
+ {
+ scopeName: 'test-scope',
+ path: ['', 'test-scope'],
+ title: 'test-scope',
+ },
+ ]);
+ });
+ });
+
+ describe('closeAndApply', () => {
+ it('should close the selector and apply the selected scopes', async () => {
+ await service.open();
+
+ const rootNode = service.state.nodes[''];
+ rootNode.nodes['test-scope'] = {
+ ...mockNode,
+ selectable: true,
+ linkId: 'test-scope',
+ };
+
+ service.toggleNodeSelect({ path: ['', 'test-scope'] });
+ await service.closeAndApply();
+
+ expect(service.state.opened).toBe(false);
+ expect(dashboardsService.fetchDashboards).toHaveBeenCalledWith(['test-scope']);
+ });
+ });
+
+ describe('apply', () => {
+ it('should apply the selected scopes without closing the selector', async () => {
+ await service.open();
+
+ const rootNode = service.state.nodes[''];
+ rootNode.nodes['test-scope'] = {
+ ...mockNode,
+ selectable: true,
+ linkId: 'test-scope',
+ };
+
+ service.toggleNodeSelect({ path: ['', 'test-scope'] });
+ await service.apply();
+
+ expect(service.state.opened).toBe(true);
+ expect(dashboardsService.fetchDashboards).toHaveBeenCalledWith(['test-scope']);
+ });
+ });
+
+ describe('resetSelection', () => {
+ it('should reset treeScopes to match selectedScopes', async () => {
+ await service.open();
+ await service.changeScopes(['test-scope']);
+
+ service.resetSelection();
+ expect(service.state.treeScopes).toEqual([
+ {
+ scopeName: 'test-scope',
+ path: ['', 'test-scope'],
+ title: 'test-scope',
+ },
+ ]);
+ });
+ });
+
+ describe('removeAllScopes', () => {
+ it('should remove all selected scopes', async () => {
+ await service.open();
+
+ const rootNode = service.state.nodes[''];
+ rootNode.nodes['test-scope'] = {
+ ...mockNode,
+ selectable: true,
+ linkId: 'test-scope',
+ };
+
+ service.toggleNodeSelect({ path: ['', 'test-scope'] });
+ await service.apply();
+ await service.removeAllScopes();
+
+ expect(service.state.selectedScopes).toEqual([]);
+ expect(service.state.treeScopes).toEqual([]);
+ });
+ });
+});
diff --git a/public/app/features/scopes/selector/ScopesSelectorService.ts b/public/app/features/scopes/selector/ScopesSelectorService.ts
index 8c34ddd68e7..edccb3cc5e1 100644
--- a/public/app/features/scopes/selector/ScopesSelectorService.ts
+++ b/public/app/features/scopes/selector/ScopesSelectorService.ts
@@ -5,7 +5,7 @@ import { ScopesServiceBase } from '../ScopesServiceBase';
import { ScopesDashboardsService } from '../dashboards/ScopesDashboardsService';
import { getEmptyScopeObject } from '../utils';
-import { NodeReason, NodesMap, SelectedScope, TreeScope } from './types';
+import { Node, NodeReason, NodesMap, SelectedScope, ToggleNode, TreeScope } from './types';
const RECENT_SCOPES_KEY = 'grafana.scopes.recent';
@@ -67,16 +67,23 @@ export class ScopesSelectorService extends ScopesServiceBase 1) {
- const pathToParent = path.slice(0, path.length - 1);
- currentLevel = getNodesAtPath(nodes, pathToParent);
+ const pathToParent = path.slice(1, path.length - 1);
+ parentNode = getNodesAtPath(nodes[''], pathToParent);
loadingNodeName = last(path)!;
+
+ if (!parentNode) {
+ console.warn('No parent node found for path:', path);
+ return;
+ }
+
+ currentNode = parentNode.nodes[loadingNodeName];
}
- const currentNode = currentLevel[loadingNodeName];
const differentQuery = currentNode.query !== query;
currentNode.expanded = expanded;
@@ -125,47 +132,78 @@ export class ScopesSelectorService extends ScopesServiceBase {
- let treeScopes = [...this.state.treeScopes];
+ public toggleNodeSelect = (node: ToggleNode) => {
+ if ('scopeName' in node) {
+ // This is for a case where we don't have a path yet. For example on init we get the selected from url, but
+ // just the names. If we want to deselect them without knowing where in the tree they are we can just pass the
+ // name.
- let parentNode = this.state.nodes[''];
-
- for (let idx = 1; idx < path.length - 1; idx++) {
- parentNode = parentNode.nodes[path[idx]];
+ const newTreeScopes = this.state.treeScopes.filter((s) => s.scopeName !== node.scopeName);
+ if (newTreeScopes.length !== this.state.treeScopes.length) {
+ this.updateState({ treeScopes: newTreeScopes });
+ return;
+ }
}
- const nodeName = path[path.length - 1];
- const { linkId } = parentNode.nodes[nodeName];
+ if (!node.path) {
+ console.warn('Node cannot be selected without both path and name', node);
+ return;
+ }
+
+ let treeScopes = [...this.state.treeScopes];
+ const parentNode = getNodesAtPath(this.state.nodes[''], node.path.slice(1, -1));
+
+ if (!parentNode) {
+ // Either the path is wrong or we don't have the nodes loaded yet. So let's check the selected tree nodes if we
+ // can remove something based on scope name.
+ const scopeName = node.path.at(-1);
+ const newTreeScopes = treeScopes.filter((s) => s.scopeName !== scopeName);
+ if (newTreeScopes.length !== treeScopes.length) {
+ this.updateState({ treeScopes: newTreeScopes });
+ } else {
+ console.warn('No node found for path:', node.path);
+ }
+ return;
+ }
+
+ const nodeName = node.path[node.path.length - 1];
+ const { linkId, title } = parentNode.nodes[nodeName];
const selectedIdx = treeScopes.findIndex(({ scopeName }) => scopeName === linkId);
if (selectedIdx === -1) {
+ // We are selecting a new node.
+
// We prefetch the scope when clicking on it. This will mean that once the selection is applied in closeAndApply()
// we already have all the scopes in cache and don't need to fetch all of them again is multiple requests.
this.apiClient.fetchScope(linkId!);
+ const treeScope: TreeScope = {
+ scopeName: linkId!,
+ path: node.path,
+ title,
+ };
+
+ // We cannot select multiple scopes with different parents only. In that case we will just deselect all the
+ // others.
const selectedFromSameNode =
treeScopes.length === 0 ||
Object.values(parentNode.nodes).some(({ linkId }) => linkId === treeScopes[0].scopeName);
- const treeScope = {
- scopeName: linkId!,
- path,
- };
-
this.updateState({
treeScopes: parentNode?.disableMultiSelect || !selectedFromSameNode ? [treeScope] : [...treeScopes, treeScope],
});
} else {
+ // We are deselecting already selected node.
treeScopes.splice(selectedIdx, 1);
-
this.updateState({ treeScopes });
}
};
- changeScopes = (scopeNames: string[]) => this.setNewScopes(scopeNames.map((scopeName) => ({ scopeName, path: [] })));
+ changeScopes = (scopeNames: string[]) => {
+ return this.setNewScopes(scopeNames.map((scopeName) => ({ scopeName, path: [], title: scopeName })));
+ };
/**
* Apply the selected scopes. Apart from setting the scopes it also fetches the scope metadata and also loads the
@@ -178,8 +216,8 @@ export class ScopesSelectorService extends ScopesServiceBase ({
- scope: getEmptyScopeObject(scopeName),
+ let selectedScopes = treeScopes.map(({ scopeName, path, title }) => ({
+ scope: getEmptyScopeObject(scopeName, title),
path,
}));
@@ -189,11 +227,26 @@ export class ScopesSelectorService extends ScopesServiceBase scope.metadata.name));
- selectedScopes = await this.apiClient.fetchMultipleScopes(treeScopes);
- if (selectedScopes.length > 0) {
- this.addRecentScopes(selectedScopes);
+ if (treeScopes.length > 0) {
+ selectedScopes = await this.apiClient.fetchMultipleScopes(treeScopes);
+ if (selectedScopes.length > 0) {
+ this.addRecentScopes(selectedScopes);
+ }
}
- this.updateState({ selectedScopes, loading: false });
+
+ // Make sure the treeScopes also have the right title as we use it to display the selection in the UI while to set
+ // the scopes you just need the name/id.
+ const updatedTreeScopes = treeScopes.map((treeScope) => {
+ const matchingSelectedScope = selectedScopes.find(
+ (selectedScope) => selectedScope.scope.metadata.name === treeScope.scopeName
+ );
+ return {
+ ...treeScope,
+ title: matchingSelectedScope?.scope.spec.title || treeScope.title,
+ };
+ });
+
+ this.updateState({ selectedScopes, treeScopes: updatedTreeScopes, loading: false });
};
public removeAllScopes = () => this.setNewScopes([]);
@@ -255,7 +308,15 @@ export class ScopesSelectorService extends ScopesServiceBase {
this.updateState({ opened: false });
- this.setNewScopes();
+ return this.apply();
+ };
+
+ public apply = () => {
+ return this.setNewScopes();
+ };
+
+ public resetSelection = () => {
+ this.updateState({ treeScopes: getTreeScopesFromSelectedScopes(this.state.selectedScopes) });
};
}
@@ -279,6 +340,7 @@ function getTreeScopesFromSelectedScopes(scopes: SelectedScope[]): TreeScope[] {
return scopes.map(({ scope, path }) => ({
scopeName: scope.metadata.name,
path,
+ title: scope.spec.title,
}));
}
@@ -352,12 +414,15 @@ function expandNodes(nodes: NodesMap, path: string[]): NodesMap {
return nodes;
}
-function getNodesAtPath(nodes: NodesMap, path: string[]): NodesMap {
- let currentNodes = nodes;
+function getNodesAtPath(node: Node, path: string[]): Node | undefined {
+ let currentNode = node;
for (const section of path) {
- currentNodes = currentNodes[section].nodes;
+ if (currentNode === undefined) {
+ return undefined;
+ }
+ currentNode = currentNode.nodes[section];
}
- return currentNodes;
+ return currentNode;
}
diff --git a/public/app/features/scopes/selector/ScopesTreeItem.tsx b/public/app/features/scopes/selector/ScopesTreeItem.tsx
index 9c029ea25b4..f0326f2ffca 100644
--- a/public/app/features/scopes/selector/ScopesTreeItem.tsx
+++ b/public/app/features/scopes/selector/ScopesTreeItem.tsx
@@ -75,7 +75,7 @@ export function ScopesTreeItem({
label=""
data-testid={`scopes-tree-${type}-${childNode.name}-radio`}
onClick={() => {
- onNodeSelectToggle(childNodePath);
+ onNodeSelectToggle({ path: childNodePath });
}}
/>
) : (
@@ -83,7 +83,7 @@ export function ScopesTreeItem({
checked={selected}
data-testid={`scopes-tree-${type}-${childNode.name}-checkbox`}
onChange={() => {
- onNodeSelectToggle(childNodePath);
+ onNodeSelectToggle({ path: childNodePath });
}}
/>
)
diff --git a/public/app/features/scopes/selector/types.ts b/public/app/features/scopes/selector/types.ts
index 672a6f158b4..11d6e3c7693 100644
--- a/public/app/features/scopes/selector/types.ts
+++ b/public/app/features/scopes/selector/types.ts
@@ -23,9 +23,13 @@ export interface SelectedScope {
}
export interface TreeScope {
+ title: string;
scopeName: string;
path: string[];
}
+// Sort of partial treeScope that is used as a way to say which node should be toggled.
+export type ToggleNode = { scopeName: string; path?: string[] } | { path: string[]; scopeName?: string };
+
export type OnNodeUpdate = (path: string[], expanded: boolean, query: string) => void;
-export type OnNodeSelectToggle = (path: string[]) => void;
+export type OnNodeSelectToggle = (node: ToggleNode) => void;
diff --git a/public/app/features/scopes/tests/selector.test.ts b/public/app/features/scopes/tests/selector.test.ts
index bf94cafa922..edfda65b24a 100644
--- a/public/app/features/scopes/tests/selector.test.ts
+++ b/public/app/features/scopes/tests/selector.test.ts
@@ -96,13 +96,14 @@ describe('Selector', () => {
await selectResultApplicationsMimir();
await applyScopes();
- // Grafana,Mimir currently selected. Grafana is the first recent scope.
+ // recent scopes only show on top level, so we need to make sure the scopes tree is not exapnded.
+ await clearSelector();
+
await openSelector();
expectRecentScopesSection();
await expandRecentScopes();
expectRecentScope('Grafana');
- expectRecentScopeNotPresent('Mimir');
- expectRecentScopeNotPresent('Grafana, Mimir');
+ expectRecentScope('Grafana, Mimir');
await selectRecentScope('Grafana');
expectScopesSelectorValue('Grafana');
diff --git a/public/app/features/scopes/utils.ts b/public/app/features/scopes/utils.ts
index d80a19d239c..d86a6f69b02 100644
--- a/public/app/features/scopes/utils.ts
+++ b/public/app/features/scopes/utils.ts
@@ -1,11 +1,11 @@
import { Scope } from '@grafana/data';
-export function getEmptyScopeObject(name: string): Scope {
+export function getEmptyScopeObject(name: string, title?: string): Scope {
return {
metadata: { name },
spec: {
filters: [],
- title: name,
+ title: title || name,
type: '',
category: '',
description: '',
diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json
index 47654e02c49..2e2bd544668 100644
--- a/public/locales/en-US/grafana.json
+++ b/public/locales/en-US/grafana.json
@@ -2785,13 +2785,18 @@
},
"command-palette": {
"action": {
- "change-theme": "Change theme...",
+ "change-theme": "Change theme",
"dark-theme": "Dark",
- "light-theme": "Light"
+ "light-theme": "Light",
+ "scopes": "Scopes"
},
"empty-state": {
"message": "No results found"
},
+ "scopes": {
+ "apply-selected-scopes": "Apply",
+ "selected-scopes-label": "Scopes: "
+ },
"search-box": {
"placeholder": "Search or jump to..."
},