mirror of https://github.com/grafana/grafana
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 filepull/100320/merge
parent
61430f027f
commit
31903c7abc
@ -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; |
||||
} |
Loading…
Reference in new issue