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
pull/100320/merge
Andrej Ocenas 1 month ago committed by GitHub
parent 61430f027f
commit 31903c7abc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 8
      public/app/features/commandPalette/CommandPalette.tsx
  2. 307
      public/app/features/commandPalette/actions/scopeActions.test.tsx
  3. 169
      public/app/features/commandPalette/actions/scopeActions.tsx
  4. 191
      public/app/features/commandPalette/actions/scopesUtils.test.ts
  5. 144
      public/app/features/commandPalette/actions/scopesUtils.ts
  6. 252
      public/app/features/commandPalette/actions/useActions.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';

@ -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);
});
});

@ -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 ? (
<ScopesRow
nodes={nodes}
scopes={scopes}
deselectScope={deselectScope}
selectedScopes={selectedScopes}
apply={finalApply}
isDirty={isDirty}
/>
) : 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<CommandPaletteAction[] | undefined>(undefined);
const searchQueryRef = useRef<string>();
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;
}

@ -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');
});
});

@ -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;
}

@ -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 ? (
<ScopesRow
nodes={nodes}
scopes={scopes}
deselectScope={deselectScope}
selectedScopes={selectedScopes}
apply={finalApply}
isDirty={isDirty}
/>
) : null,
};
}
function useGlobalScopesSearch(
searchQuery: string,
searchAllNodes: (search: string, limit: number) => Promise<ScopeNode[]>,
parentId?: string | null
) {
const [nodes, setNodes] = useState<NodesMap | undefined>(undefined);
const searchQueryRef = useRef<string>();
// 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;
}

Loading…
Cancel
Save