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
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user