From 31903c7abc1c747c055c25d39d7a5580f311b113 Mon Sep 17 00:00:00 2001 From: Andrej Ocenas Date: Mon, 16 Jun 2025 14:38:27 +0200 Subject: [PATCH] Command palette: Add tests for scope actions (#106660) * Don't show result from other parents * Use global search hook for scopes even inside scopes category * Remove console.log * Move code to separate files * Add tests * Renamed utils file --- .../commandPalette/CommandPalette.tsx | 8 +- .../actions/scopeActions.test.tsx | 307 ++++++++++++++++++ .../commandPalette/actions/scopeActions.tsx | 169 ++++++++++ .../actions/scopesUtils.test.ts | 191 +++++++++++ .../commandPalette/actions/scopesUtils.ts | 144 ++++++++ .../commandPalette/actions/useActions.tsx | 252 +------------- 6 files changed, 814 insertions(+), 257 deletions(-) create mode 100644 public/app/features/commandPalette/actions/scopeActions.test.tsx create mode 100644 public/app/features/commandPalette/actions/scopeActions.tsx create mode 100644 public/app/features/commandPalette/actions/scopesUtils.test.ts create mode 100644 public/app/features/commandPalette/actions/scopesUtils.ts 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; -}