diff --git a/public/app/features/commandPalette/CommandPalette.tsx b/public/app/features/commandPalette/CommandPalette.tsx
index ac4d2f07acc..b84716ba7f5 100644
--- a/public/app/features/commandPalette/CommandPalette.tsx
+++ b/public/app/features/commandPalette/CommandPalette.tsx
@@ -15,12 +15,8 @@ import { KBarResults } from './KBarResults';
import { KBarSearch } from './KBarSearch';
import { ResultItem } from './ResultItem';
import { useSearchResults } from './actions/dashboardActions';
-import {
- useRegisterRecentDashboardsActions,
- useRegisterRecentScopesActions,
- useRegisterScopesActions,
- useRegisterStaticActions,
-} from './actions/useActions';
+import { useRegisterRecentScopesActions, useRegisterScopesActions } from './actions/scopeActions';
+import { useRegisterRecentDashboardsActions, useRegisterStaticActions } from './actions/useActions';
import { CommandPaletteAction } from './types';
import { useMatches } from './useMatches';
diff --git a/public/app/features/commandPalette/actions/scopeActions.test.tsx b/public/app/features/commandPalette/actions/scopeActions.test.tsx
new file mode 100644
index 00000000000..093ac40b1c7
--- /dev/null
+++ b/public/app/features/commandPalette/actions/scopeActions.test.tsx
@@ -0,0 +1,307 @@
+import { waitFor, renderHook } from '@testing-library/react';
+import { setIn } from 'immutable';
+import { useRegisterActions } from 'kbar';
+
+import { config } from '@grafana/runtime';
+
+import { NodesMap, TreeNode } from '../../scopes/selector/types';
+
+import { useRegisterScopesActions } from './scopeActions';
+import { useScopeServicesState } from './scopesUtils';
+
+// Mock dependencies
+jest.mock('kbar', () => ({
+ useRegisterActions: jest.fn(),
+}));
+
+jest.mock('./scopesUtils', () => {
+ return {
+ ...jest.requireActual('./scopesUtils'),
+ useScopeServicesState: jest.fn(),
+ };
+});
+
+const mockScopeServicesState = {
+ updateNode: jest.fn(),
+ selectScope: jest.fn(),
+ resetSelection: jest.fn(),
+ nodes: {},
+ tree: {
+ scopeNodeId: '',
+ expanded: false,
+ query: '',
+ },
+ selectedScopes: [],
+ appliedScopes: [],
+ deselectScope: jest.fn(),
+ apply: jest.fn(),
+ searchAllNodes: jest.fn(),
+ scopes: [],
+};
+
+const rootScopeAction = {
+ id: 'scopes',
+ keywords: 'scopes filters',
+ name: 'Scopes',
+ priority: 8,
+ section: 'Scopes',
+};
+
+const nodes: NodesMap = {
+ scope1: {
+ metadata: { name: 'scope1' },
+ spec: {
+ title: 'Scope 1',
+ nodeType: 'leaf',
+ linkId: 'link1',
+ parentName: '',
+ },
+ },
+ scope2: {
+ metadata: { name: 'scope2' },
+ spec: {
+ title: 'Scope 2',
+ nodeType: 'leaf',
+ linkId: 'link2',
+ parentName: '',
+ },
+ },
+};
+
+const tree: TreeNode = {
+ scopeNodeId: 'root',
+ expanded: true,
+ query: '',
+ children: {
+ scope1: { scopeNodeId: 'scope1', expanded: false, children: {}, query: '' },
+ scope2: { scopeNodeId: 'scope2', expanded: false, children: {}, query: '' },
+ },
+};
+
+describe('useRegisterScopesActions', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ (useScopeServicesState as jest.Mock).mockReturnValue(mockScopeServicesState);
+ config.featureToggles.scopeFilters = true;
+ config.featureToggles.scopeSearchAllLevels = true;
+ });
+
+ it('should return undefined scopesRow when feature toggle is off', () => {
+ config.featureToggles.scopeFilters = false;
+
+ const onApply = jest.fn();
+ const { result } = renderHook(() => {
+ return useRegisterScopesActions('', onApply);
+ });
+
+ expect(result.current.scopesRow).toBeUndefined();
+ expect(useRegisterActions).not.toHaveBeenCalled();
+ });
+
+ it('should register scope tree actions and return scopesRow when scopes are selected', () => {
+ const mockUpdateNode = jest.fn();
+
+ // First run with empty scopes in the scopes service
+ (useScopeServicesState as jest.Mock).mockReturnValue({
+ ...mockScopeServicesState,
+ updateNode: mockUpdateNode,
+ selectedScopes: [{ scopeId: 'scope1', name: 'Scope 1' }],
+ });
+
+ const { result, rerender } = renderHook(() => {
+ return useRegisterScopesActions('', jest.fn());
+ });
+
+ expect(mockUpdateNode).toHaveBeenCalledWith('', true, '');
+ expect(useRegisterActions).toHaveBeenLastCalledWith([rootScopeAction], [[rootScopeAction]]);
+ expect(result.current.scopesRow).toBeDefined();
+
+ // Simulate loading of scopes in the service
+ (useScopeServicesState as jest.Mock).mockReturnValue({
+ ...mockScopeServicesState,
+ updateNode: mockUpdateNode,
+ selectedScopes: [{ scopeId: 'scope1', name: 'Scope 1' }],
+ nodes,
+ tree,
+ appliedScopes: [],
+ });
+
+ const actions = [
+ rootScopeAction,
+ {
+ id: 'scopes/scope1',
+ keywords: 'Scope 1 scope1',
+ name: 'Scope 1',
+ parent: 'scopes',
+ perform: expect.any(Function),
+ priority: 8,
+ },
+ {
+ id: 'scopes/scope2',
+ keywords: 'Scope 2 scope2',
+ name: 'Scope 2',
+ parent: 'scopes',
+ perform: expect.any(Function),
+ priority: 8,
+ },
+ ];
+
+ rerender();
+ expect(useRegisterActions).toHaveBeenLastCalledWith(actions, [actions]);
+ });
+
+ it('should load next level of scopes', () => {
+ const mockUpdateNode = jest.fn();
+
+ // First run with empty scopes in the scopes service
+ (useScopeServicesState as jest.Mock).mockReturnValue({
+ ...mockScopeServicesState,
+ updateNode: mockUpdateNode,
+ nodes,
+ tree,
+ });
+
+ renderHook(() => {
+ return useRegisterScopesActions('', jest.fn(), 'scopes/scope1');
+ });
+
+ expect(mockUpdateNode).toHaveBeenCalledWith('scope1', true, '');
+ });
+
+ it('does not return component if no scopes are selected', () => {
+ const { result } = renderHook(() => {
+ return useRegisterScopesActions('', jest.fn(), 'scopes/scope1');
+ });
+ expect(result.current.scopesRow).toBeNull();
+ });
+
+ it('should use global scope search when in global cmdk level', async () => {
+ mockScopeServicesState.searchAllNodes.mockResolvedValue([
+ setIn(nodes.scope1, ['spec', 'parentName'], 'some parent'),
+ ]);
+
+ renderHook(() => {
+ return useRegisterScopesActions('scopes1', jest.fn());
+ });
+
+ // Wait for the async search to complete
+ await waitFor(() => {
+ expect(mockScopeServicesState.searchAllNodes).toHaveBeenCalledWith('scopes1', 10);
+ });
+
+ const actions = [
+ rootScopeAction,
+ {
+ id: 'scopes/scope1',
+ keywords: 'Scope 1 scope1',
+ name: 'Scope 1',
+ perform: expect.any(Function),
+ priority: 8,
+ section: 'Scopes',
+ subtitle: 'some parent',
+ },
+ ];
+
+ expect(useRegisterActions).toHaveBeenLastCalledWith(actions, [actions]);
+ });
+
+ it('should use global scope search when "scope" cmdk level', async () => {
+ mockScopeServicesState.searchAllNodes.mockResolvedValue([
+ setIn(nodes.scope1, ['spec', 'parentName'], 'some parent'),
+ ]);
+
+ renderHook(() => {
+ return useRegisterScopesActions('scopes1', jest.fn(), 'scopes');
+ });
+
+ // Wait for the async search to complete
+ await waitFor(() => {
+ expect(mockScopeServicesState.searchAllNodes).toHaveBeenCalledWith('scopes1', 10);
+ });
+
+ const actions = [
+ rootScopeAction,
+ {
+ id: 'scopes/scope1',
+ keywords: 'Scope 1 scope1',
+ name: 'Scope 1',
+ perform: expect.any(Function),
+ priority: 8,
+ // The main difference here is that we map it to a parent if we are in the "scopes" section of the cmdK.
+ // In the previous test the scope actions were mapped to global level to show correctly.
+ parent: 'scopes',
+ },
+ ];
+
+ expect(useRegisterActions).toHaveBeenLastCalledWith(actions, [actions]);
+ });
+
+ it('should filter non leaf nodes from global scope search', async () => {
+ mockScopeServicesState.searchAllNodes.mockResolvedValue([
+ nodes.scope1,
+ setIn(nodes.scope2, ['spec', 'nodeType'], 'container'),
+ ]);
+
+ renderHook(() => {
+ return useRegisterScopesActions('scopes1', jest.fn());
+ });
+ await waitFor(() => {
+ expect(mockScopeServicesState.searchAllNodes).toHaveBeenCalledWith('scopes1', 10);
+ });
+
+ // Checking the second call as first one registers just the global scopes action
+ expect((useRegisterActions as jest.Mock).mock.calls[1][0]).toHaveLength(2);
+ expect((useRegisterActions as jest.Mock).mock.calls[1][0]).toMatchObject([
+ { id: 'scopes' },
+ { id: 'scopes/scope1' },
+ ]);
+ });
+
+ it('should not use global scope search when searching in some deeper scope category', async () => {
+ const mockUpdateNode = jest.fn();
+
+ // First run with empty scopes in the scopes service
+ (useScopeServicesState as jest.Mock).mockReturnValue({
+ ...mockScopeServicesState,
+ updateNode: mockUpdateNode,
+ nodes,
+ tree,
+ });
+
+ renderHook(() => {
+ return useRegisterScopesActions('something', jest.fn(), 'scopes/scope1');
+ });
+
+ expect(mockUpdateNode).toHaveBeenCalledWith('scope1', true, 'something');
+ expect(mockScopeServicesState.searchAllNodes).not.toHaveBeenCalled();
+ });
+
+ it('should not use global scope search if feature flag is off', async () => {
+ config.featureToggles.scopeSearchAllLevels = false;
+ const mockUpdateNode = jest.fn();
+ // First run with empty scopes in the scopes service
+ (useScopeServicesState as jest.Mock).mockReturnValue({
+ ...mockScopeServicesState,
+ updateNode: mockUpdateNode,
+ nodes,
+ tree,
+ });
+
+ renderHook(() => {
+ return useRegisterScopesActions('something', jest.fn(), '');
+ });
+
+ expect(mockUpdateNode).toHaveBeenCalledWith('', true, 'something');
+ expect(mockScopeServicesState.searchAllNodes).not.toHaveBeenCalled();
+ });
+
+ it('should cleanup on unmount', () => {
+ const { unmount } = renderHook(() => {
+ return useRegisterScopesActions('', jest.fn(), '');
+ });
+
+ expect(mockScopeServicesState.resetSelection).toHaveBeenCalledTimes(1);
+ unmount();
+ expect(mockScopeServicesState.resetSelection).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/public/app/features/commandPalette/actions/scopeActions.tsx b/public/app/features/commandPalette/actions/scopeActions.tsx
new file mode 100644
index 00000000000..724a3d49d46
--- /dev/null
+++ b/public/app/features/commandPalette/actions/scopeActions.tsx
@@ -0,0 +1,169 @@
+import { useRegisterActions } from 'kbar';
+import { last } from 'lodash';
+import { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
+
+import { config } from '@grafana/runtime';
+
+import { ScopesRow } from '../ScopesRow';
+import { CommandPaletteAction } from '../types';
+
+import { getRecentScopesActions } from './recentScopesActions';
+import {
+ getScopesParentAction,
+ mapScopeNodeToAction,
+ mapScopesNodesTreeToActions,
+ useScopeServicesState,
+} from './scopesUtils';
+
+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 } {
+ // Conditional hooks, but this should only change if feature toggles changes so not in runtime.
+ if (!config.featureToggles.scopeFilters) {
+ return { scopesRow: undefined };
+ }
+
+ const globalScopeActions = useGlobalScopesSearch(searchQuery, parentId);
+ const scopeTreeActions = useScopeTreeActions(searchQuery, parentId);
+
+ // If we have global search actions we use those. Inside the hook the search should be conditional based on where
+ // in the command palette we are.
+ const nodesActions = globalScopeActions || scopeTreeActions;
+
+ useRegisterActions(nodesActions, [nodesActions]);
+
+ // Returns a component to show what scopes are selected or applied.
+ return useScopesRow(onApply);
+}
+
+/**
+ * Register actions based on the scopes tree structure. This handles the scope service updates and uses it as the
+ * source of truth.
+ * @param searchQuery
+ * @param parentId
+ */
+function useScopeTreeActions(searchQuery: string, parentId?: string | null) {
+ const { updateNode, selectScope, resetSelection, nodes, tree, selectedScopes } = useScopeServicesState();
+
+ // Initialize the scopes the first time this runs and reset the scopes that were selected on unmount.
+ useEffect(() => {
+ updateNode('', true, '');
+ resetSelection();
+ return () => {
+ resetSelection();
+ };
+ }, [updateNode, resetSelection]);
+
+ // Load the next level of scopes when the parentId changes.
+ useEffect(() => {
+ const parentScopeId = !parentId || parentId === 'scopes' ? '' : last(parentId.split('/'))!;
+ updateNode(parentScopeId, true, searchQuery);
+ }, [updateNode, searchQuery, parentId]);
+
+ return useMemo(
+ () => mapScopesNodesTreeToActions(nodes, tree!, selectedScopes, selectScope),
+ [nodes, tree, selectedScopes, selectScope]
+ );
+}
+
+/**
+ * Returns an element to add to the command palette in case some scopes are selected, showing them and an apply
+ * button.
+ * @param onApply
+ */
+function useScopesRow(onApply: () => void) {
+ const { nodes, scopes, selectedScopes, appliedScopes, deselectScope, apply } = useScopeServicesState();
+
+ // Check if we have a different selection than what is already applied. Used to show the apply button.
+ const isDirty =
+ appliedScopes
+ .map((t) => t.scopeId)
+ .sort()
+ .join('') !==
+ selectedScopes
+ .map((s) => s.scopeId)
+ .sort()
+ .join('');
+
+ const finalApply = useCallback(() => {
+ apply();
+ onApply();
+ }, [apply, onApply]);
+
+ // Add a 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 || selectedScopes?.length ? (
+
+ ) : null,
+ };
+}
+
+/**
+ * Register actions based on global search call. This returns actions that are separate from the scope service tree
+ * and are just flat list without updating the scope service state.
+ * @param searchQuery
+ * @param parentId
+ */
+function useGlobalScopesSearch(searchQuery: string, parentId?: string | null) {
+ const { selectScope, searchAllNodes } = useScopeServicesState();
+ const [actions, setActions] = useState(undefined);
+ const searchQueryRef = useRef();
+
+ useEffect(() => {
+ if ((!parentId || parentId === 'scopes') && searchQuery && config.featureToggles.scopeSearchAllLevels) {
+ // We only search globally if there is no parentId
+ searchQueryRef.current = searchQuery;
+ searchAllNodes(searchQuery, 10).then((nodes) => {
+ if (searchQueryRef.current === searchQuery) {
+ // Only show leaf nodes because otherwise there are issues with navigating to a category without knowing
+ // where in the tree it is.
+ const leafNodes = nodes.filter((node) => node.spec.nodeType === 'leaf');
+ const actions = [getScopesParentAction()];
+ for (const node of leafNodes) {
+ actions.push(mapScopeNodeToAction(node, selectScope, parentId || undefined));
+ }
+ setActions(actions);
+ }
+ });
+ } else {
+ searchQueryRef.current = undefined;
+ setActions(undefined);
+ }
+ }, [searchAllNodes, searchQuery, parentId, selectScope]);
+
+ return actions;
+}
diff --git a/public/app/features/commandPalette/actions/scopesUtils.test.ts b/public/app/features/commandPalette/actions/scopesUtils.test.ts
new file mode 100644
index 00000000000..0fe22c0f0b3
--- /dev/null
+++ b/public/app/features/commandPalette/actions/scopesUtils.test.ts
@@ -0,0 +1,191 @@
+import { setIn } from 'immutable';
+
+import { ScopeNode } from '@grafana/data';
+
+import { NodesMap, SelectedScope, TreeNode } from '../../scopes/selector/types';
+import { SCOPES_PRIORITY } from '../values';
+
+import { mapScopeNodeToAction, mapScopesNodesTreeToActions } from './scopesUtils';
+
+const scopeNode: ScopeNode = {
+ metadata: { name: 'scope1' },
+ spec: {
+ title: 'Scope 1',
+ nodeType: 'leaf',
+ linkId: 'link1',
+ parentName: 'Parent Scope',
+ },
+};
+
+describe('mapScopeNodeToAction', () => {
+ const mockSelectScope = jest.fn();
+
+ it('should map a leaf scope node to an action with a parent', () => {
+ const action = mapScopeNodeToAction(scopeNode, mockSelectScope, 'parent1');
+
+ expect(action).toEqual({
+ id: 'parent1/scope1',
+ name: 'Scope 1',
+ keywords: 'Scope 1 scope1',
+ priority: SCOPES_PRIORITY,
+ parent: 'parent1',
+ perform: expect.any(Function),
+ });
+ });
+
+ it('should map a non-leaf scope node to an action with a parent (without perform)', () => {
+ const nonLeafScopeNode = setIn(scopeNode, ['spec', 'nodeType'], 'container');
+ const action = mapScopeNodeToAction(nonLeafScopeNode, mockSelectScope, 'parent1');
+
+ expect(action).toEqual({
+ id: 'parent1/scope1',
+ name: 'Scope 1',
+ keywords: 'Scope 1 scope1',
+ priority: SCOPES_PRIORITY,
+ parent: 'parent1',
+ });
+
+ // Non-leaf nodes don't have a perform function
+ expect(action.perform).toBeUndefined();
+ });
+
+ it('should map a scope node to an action without a parent', () => {
+ const action = mapScopeNodeToAction(scopeNode, mockSelectScope);
+
+ expect(action).toEqual({
+ id: 'scopes/scope1',
+ name: 'Scope 1',
+ keywords: 'Scope 1 scope1',
+ priority: SCOPES_PRIORITY,
+ section: 'Scopes',
+ subtitle: 'Parent Scope',
+ perform: expect.any(Function),
+ });
+ });
+});
+
+const nodes: NodesMap = {
+ scope1: {
+ metadata: { name: 'scope1' },
+ spec: {
+ title: 'Scope 1',
+ nodeType: 'leaf',
+ linkId: 'link1',
+ parentName: '',
+ },
+ },
+ scope2: {
+ metadata: { name: 'scope2' },
+ spec: {
+ title: 'Scope 2',
+ nodeType: 'leaf',
+ linkId: 'link2',
+ parentName: '',
+ },
+ },
+ scope3: {
+ metadata: { name: 'scope3' },
+ spec: {
+ title: 'Scope 3',
+ nodeType: 'container',
+ linkId: 'link3',
+ parentName: '',
+ },
+ },
+ scope4: {
+ metadata: { name: 'scope4' },
+ spec: {
+ title: 'Scope 4',
+ nodeType: 'leaf',
+ linkId: 'link4',
+ parentName: 'Scope 3',
+ },
+ },
+};
+
+const tree: TreeNode = {
+ scopeNodeId: 'root',
+ expanded: true,
+ query: '',
+ children: {
+ scope1: { scopeNodeId: 'scope1', expanded: false, children: {}, query: '' },
+ scope2: { scopeNodeId: 'scope2', expanded: false, children: {}, query: '' },
+ scope3: {
+ scopeNodeId: 'scope3',
+ expanded: true,
+ query: '',
+ children: {
+ scope4: { scopeNodeId: 'scope4', expanded: false, children: {}, query: '' },
+ },
+ },
+ },
+};
+
+describe('mapScopesNodesTreeToActions', () => {
+ const mockSelectScope = jest.fn();
+
+ it('should map tree nodes to actions and skip selected scopes', () => {
+ const selectedScopes: SelectedScope[] = [{ scopeNodeId: 'scope2', scopeId: 'link2' }];
+ const actions = mapScopesNodesTreeToActions(nodes, tree, selectedScopes, mockSelectScope);
+
+ // We expect 4 actions: the parent action + scope1 + scope3 + scope4
+ // scope2 should be skipped because it's selected
+ expect(actions).toHaveLength(4);
+
+ // Verify parent action is first
+ expect(actions[0].id).toBe('scopes');
+
+ // Verify scope1 action
+ expect(actions.find((a) => a.id === 'scopes/scope1')).toBeTruthy();
+
+ // Verify scope2 is skipped (it's selected)
+ expect(actions.find((a) => a.id === 'scopes/scope2')).toBeFalsy();
+
+ const scope3Action = actions.find((a) => a.id === 'scopes/scope3');
+ expect(scope3Action).toBeTruthy();
+ expect(scope3Action?.perform).toBeUndefined(); // No perform for branch nodes
+
+ // Verify scope4 action (child of scope3)
+ const scope4Action = actions.find((a) => a.id === 'scopes/scope3/scope4');
+ expect(scope4Action).toBeTruthy();
+ expect(scope4Action?.perform).toBeDefined();
+ });
+
+ it('should skip selected scopes if we only have scopeId of selected scope', () => {
+ const selectedScopes: SelectedScope[] = [{ scopeId: 'link2' }];
+ const actions = mapScopesNodesTreeToActions(nodes, tree, selectedScopes, mockSelectScope);
+ expect(actions.find((a) => a.id === 'scopes/scope2')).toBeFalsy();
+ });
+
+ it('should handle empty tree children', () => {
+ const nodes: NodesMap = {};
+ const tree: TreeNode = {
+ scopeNodeId: 'root',
+ expanded: true,
+ children: {},
+ query: '',
+ };
+ const selectedScopes: SelectedScope[] = [];
+ const actions = mapScopesNodesTreeToActions(nodes, tree, selectedScopes, mockSelectScope);
+
+ // Only the parent action
+ expect(actions).toHaveLength(1);
+ expect(actions[0].id).toBe('scopes');
+ });
+
+ it('should handle undefined children', () => {
+ const nodes: NodesMap = {};
+ const tree: TreeNode = {
+ scopeNodeId: 'root',
+ expanded: true,
+ children: undefined,
+ query: '',
+ };
+ const selectedScopes: SelectedScope[] = [];
+ const actions = mapScopesNodesTreeToActions(nodes, tree, selectedScopes, mockSelectScope);
+
+ // Only the parent action
+ expect(actions).toHaveLength(1);
+ expect(actions[0].id).toBe('scopes');
+ });
+});
diff --git a/public/app/features/commandPalette/actions/scopesUtils.ts b/public/app/features/commandPalette/actions/scopesUtils.ts
new file mode 100644
index 00000000000..1a24cb47620
--- /dev/null
+++ b/public/app/features/commandPalette/actions/scopesUtils.ts
@@ -0,0 +1,144 @@
+import { useObservable } from 'react-use';
+import { Observable } from 'rxjs';
+
+import { ScopeNode } from '@grafana/data';
+import { t } from '@grafana/i18n';
+
+import { useScopesServices } from '../../scopes/ScopesContextProvider';
+import { ScopesSelectorServiceState } from '../../scopes/selector/ScopesSelectorService';
+import { NodesMap, SelectedScope, TreeNode } from '../../scopes/selector/types';
+import { CommandPaletteAction } from '../types';
+import { SCOPES_PRIORITY } from '../values';
+
+export function useScopeServicesState() {
+ const services = useScopesServices();
+ if (!services) {
+ return {
+ updateNode: () => {},
+ selectScope: () => {},
+ resetSelection: () => {},
+ searchAllNodes: () => Promise.resolve([]),
+ apply: () => {},
+ deselectScope: () => {},
+ nodes: {},
+ scopes: {},
+ selectedScopes: [],
+ appliedScopes: [],
+ tree: {
+ scopeNodeId: '',
+ expanded: false,
+ query: '',
+ },
+ };
+ }
+ const { updateNode, selectScope, resetSelection, searchAllNodes, deselectScope, apply } =
+ services.scopesSelectorService;
+ const selectorServiceState: ScopesSelectorServiceState | undefined = useObservable(
+ services.scopesSelectorService.stateObservable ?? new Observable(),
+ services.scopesSelectorService.state
+ );
+
+ return {
+ updateNode,
+ selectScope,
+ resetSelection,
+ searchAllNodes,
+ apply,
+ deselectScope,
+ ...selectorServiceState,
+ };
+}
+
+export function getScopesParentAction(): CommandPaletteAction {
+ return {
+ id: 'scopes',
+ section: t('command-palette.action.scopes', 'Scopes'),
+ name: t('command-palette.action.scopes', 'Scopes'),
+ keywords: 'scopes filters',
+ priority: SCOPES_PRIORITY,
+ };
+}
+
+export function mapScopesNodesTreeToActions(
+ nodes: NodesMap,
+ tree: TreeNode,
+ selectedScopes: SelectedScope[],
+ selectScope: (id: string) => void
+): CommandPaletteAction[] {
+ const actions: CommandPaletteAction[] = [getScopesParentAction()];
+
+ const traverse = (tree: TreeNode, parentId: string | undefined) => {
+ // TODO: not sure how and why a node.nodes can be undefined
+ if (!tree.children || Object.keys(tree.children).length === 0) {
+ return;
+ }
+
+ for (const key of Object.keys(tree.children)) {
+ const childTreeNode = tree.children[key];
+ const child = nodes[key];
+
+ const scopeIsSelected = selectedScopes.some((s) => {
+ if (s.scopeNodeId) {
+ return s.scopeNodeId === child.metadata.name;
+ } else {
+ return s.scopeId === child.spec.linkId;
+ }
+ });
+
+ // Selected scopes are not shown in the list but in a separate section of the command palette.
+ if (child.spec.nodeType === 'leaf' && scopeIsSelected) {
+ continue;
+ }
+ let action = mapScopeNodeToAction(child, selectScope, parentId);
+ actions.push(action);
+ traverse(childTreeNode, action.id);
+ }
+ };
+
+ traverse(tree, 'scopes');
+ return actions;
+}
+
+/**
+ * Map scopeNode to cmdK action. The typing is a bit strict, and we have 2 different cases where ew create actions
+ * from global search sort of flatten out or a part of the tree structure.
+ * @param scopeNode
+ * @param selectScope
+ * @param parentId
+ */
+export function mapScopeNodeToAction(
+ scopeNode: ScopeNode,
+ selectScope: (id: string) => void,
+ parentId?: string
+): CommandPaletteAction {
+ let action: CommandPaletteAction;
+ if (parentId) {
+ action = {
+ id: `${parentId}/${scopeNode.metadata.name}`,
+ name: scopeNode.spec.title,
+ keywords: `${scopeNode.spec.title} ${scopeNode.metadata.name}`,
+ priority: SCOPES_PRIORITY,
+ parent: parentId,
+ };
+
+ // TODO: some non leaf nodes can also be selectable, but we don't have a way to show that in the UI yet.
+ if (scopeNode.spec.nodeType === 'leaf') {
+ action.perform = () => {
+ selectScope(scopeNode.metadata.name);
+ };
+ }
+ } else {
+ action = {
+ id: `scopes/${scopeNode.metadata.name}`,
+ name: scopeNode.spec.title,
+ keywords: `${scopeNode.spec.title} ${scopeNode.metadata.name}`,
+ priority: SCOPES_PRIORITY,
+ section: t('command-palette.action.scopes', 'Scopes'),
+ subtitle: scopeNode.spec.parentName,
+ perform: () => {
+ selectScope(scopeNode.metadata.name);
+ },
+ };
+ }
+ return action;
+}
diff --git a/public/app/features/commandPalette/actions/useActions.tsx b/public/app/features/commandPalette/actions/useActions.tsx
index 1a88caa451c..8b74bcb5b52 100644
--- a/public/app/features/commandPalette/actions/useActions.tsx
+++ b/public/app/features/commandPalette/actions/useActions.tsx
@@ -1,22 +1,9 @@
import { useRegisterActions } from 'kbar';
-import { fromPairs, last } from 'lodash';
-import { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
-import { useObservable } from 'react-use';
-import { Observable } from 'rxjs';
+import { useEffect, useMemo, useState } from 'react';
-import { ScopeNode } from '@grafana/data';
-import { t } from '@grafana/i18n';
-import { config } from '@grafana/runtime';
-
-import { useScopesServices } from '../../scopes/ScopesContextProvider';
-import { ScopesSelectorServiceState } from '../../scopes/selector/ScopesSelectorService';
-import { NodesMap, SelectedScope, TreeNode } 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';
@@ -46,240 +33,3 @@ export function useRegisterRecentDashboardsActions() {
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, selectScope, deselectScope, apply, resetSelection, searchAllNodes } =
- services.scopesSelectorService;
-
- // Initialize the scopes the first time this runs and reset the scopes that were selected on unmount.
- useEffect(() => {
- updateNode('', true, '');
- resetSelection();
- return () => {
- resetSelection();
- };
- }, [updateNode, resetSelection]);
-
- const globalNodes = useGlobalScopesSearch(searchQuery, searchAllNodes, parentId);
-
- // Load the next level of scopes when the parentId changes.
- useEffect(() => {
- // This is the case where we do global search instead of loading the nodes in a tree.
- if (!parentId || (parentId === 'scopes' && searchQuery)) {
- return;
- }
- updateNode(parentId === 'scopes' ? '' : last(parentId.split('/'))!, true, searchQuery);
- }, [updateNode, searchQuery, parentId]);
-
- const selectorServiceState: ScopesSelectorServiceState | undefined = useObservable(
- services.scopesSelectorService.stateObservable ?? new Observable(),
- services.scopesSelectorService.state
- );
-
- const { nodes, scopes, tree, selectedScopes, appliedScopes } = selectorServiceState;
-
- const nodesActions = useMemo(() => {
- if (globalNodes) {
- // If we have nodes from global search, we show those in a flat list.
- const actions = [getScopesParentAction()];
- for (const node of Object.values(globalNodes)) {
- actions.push(mapScopeNodeToAction(node, selectScope, parentId || undefined));
- }
- return actions;
- }
- return mapScopesNodesTreeToActions(nodes, tree!, selectedScopes, selectScope);
- }, [globalNodes, nodes, tree, selectedScopes, selectScope, parentId]);
-
- useRegisterActions(nodesActions, [nodesActions]);
-
- // Check if we have different selection than what is already applied. Used to show the apply button.
- const isDirty =
- appliedScopes
- .map((t) => t.scopeId)
- .sort()
- .join('') !==
- selectedScopes
- .map((s) => s.scopeId)
- .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 || selectedScopes?.length ? (
-
- ) : null,
- };
-}
-
-function useGlobalScopesSearch(
- searchQuery: string,
- searchAllNodes: (search: string, limit: number) => Promise,
- parentId?: string | null
-) {
- const [nodes, setNodes] = useState(undefined);
- const searchQueryRef = useRef();
-
- // Load next level of scopes when the parentId changes.
- useEffect(() => {
- if ((!parentId || parentId === 'scopes') && searchQuery && config.featureToggles.scopeSearchAllLevels) {
- // We only search globally if there is no parentId
- searchQueryRef.current = searchQuery;
- searchAllNodes(searchQuery, 10).then((nodes) => {
- if (searchQueryRef.current === searchQuery) {
- // Only show leaf nodes because otherwise there are issues with navigating to a category without knowing
- // where in the tree it is.
- const leafNodes = nodes.filter((node) => node.spec.nodeType === 'leaf');
- const nodesMap = fromPairs(leafNodes.map((n) => [n.metadata.name, n]));
- setNodes(nodesMap);
- }
- });
- } else {
- searchQueryRef.current = undefined;
- setNodes(undefined);
- }
- }, [searchAllNodes, searchQuery, parentId]);
-
- return nodes;
-}
-
-function getScopesParentAction(): CommandPaletteAction {
- return {
- id: 'scopes',
- section: t('command-palette.action.scopes', 'Scopes'),
- name: t('command-palette.action.scopes', 'Scopes'),
- keywords: 'scopes filters',
- priority: SCOPES_PRIORITY,
- };
-}
-
-function mapScopesNodesTreeToActions(
- nodes: NodesMap,
- tree: TreeNode,
- selectedScopes: SelectedScope[],
- selectScope: (id: string) => void
-): CommandPaletteAction[] {
- const actions: CommandPaletteAction[] = [getScopesParentAction()];
-
- const traverse = (tree: TreeNode, parentId: string | undefined) => {
- // TODO: not sure how and why a node.nodes can be undefined
- if (!tree.children || Object.keys(tree.children).length === 0) {
- return;
- }
-
- for (const key of Object.keys(tree.children)) {
- const childTreeNode = tree.children[key];
- const child = nodes[key];
-
- const scopeIsSelected = selectedScopes.some((s) => {
- if (s.scopeNodeId) {
- return s.scopeNodeId === child.metadata.name;
- } else {
- return s.scopeId === child.spec.linkId;
- }
- });
-
- // Selected scopes are not shown in the list but in a separate section of the command palette.
- if (child.spec.nodeType === 'leaf' && scopeIsSelected) {
- continue;
- }
- let action = mapScopeNodeToAction(child, selectScope, parentId);
- actions.push(action);
- traverse(childTreeNode, action.id);
- }
- };
-
- traverse(tree, 'scopes');
- return actions;
-}
-
-/**
- * Map scopeNode to cmdK action. The typing is a bit strict and we have 2 different cases where ew create actions
- * from global search sort of flatten out or a part of the tree structure.
- * @param scopeNode
- * @param selectScope
- * @param parentId
- */
-function mapScopeNodeToAction(
- scopeNode: ScopeNode,
- selectScope: (id: string) => void,
- parentId?: string
-): CommandPaletteAction {
- let action: CommandPaletteAction;
- if (parentId) {
- action = {
- id: `${parentId}/${scopeNode.metadata.name}`,
- name: scopeNode.spec.title,
- keywords: `${scopeNode.spec.title} ${scopeNode.metadata.name}`,
- priority: SCOPES_PRIORITY,
- parent: parentId,
- };
-
- // TODO: some non leaf nodes can also be selectable, but we don't have a way to show that in the UI yet.
- if (scopeNode.spec.nodeType === 'leaf') {
- action.perform = () => {
- selectScope(scopeNode.metadata.name);
- };
- }
- } else {
- action = {
- id: `scopes/${scopeNode.metadata.name}`,
- name: scopeNode.spec.title,
- keywords: `${scopeNode.spec.title} ${scopeNode.metadata.name}`,
- priority: SCOPES_PRIORITY,
- section: t('command-palette.action.scopes', 'Scopes'),
- subtitle: scopeNode.spec.parentName,
- perform: () => {
- selectScope(scopeNode.metadata.name);
- },
- };
- }
- return action;
-}