grafana/public/app/features/commandPalette/actions/scopeActions.tsx

193 lines
6.8 KiB
TypeScript

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 { useRecentScopesActions } from './recentScopesActions';
import {
getScopesParentAction,
mapScopeNodeToAction,
mapScopesNodesTreeToActions,
useScopeServicesState,
} from './scopesUtils';
export function useRegisterRecentScopesActions() {
const recentScopesActions = useRecentScopesActions();
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, getScopeNodes } = 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 parentNodesMap = new Map<string | undefined, string>();
if (config.featureToggles.useMultipleScopeNodesEndpoint) {
// Make sure we only request unqiue parent node names
const uniqueParentNodeNames = [
...new Set(nodes.map((node) => node.spec.parentName).filter((name) => name !== undefined)),
];
getScopeNodes(uniqueParentNodeNames).then((parentNodes) => {
for (const parentNode of parentNodes) {
parentNodesMap.set(parentNode.metadata.name, parentNode.spec.title);
}
const leafNodes = nodes.filter((node) => node.spec.nodeType === 'leaf');
const actions = [getScopesParentAction()];
for (const node of leafNodes) {
const parentName = parentNodesMap.get(node.spec.parentName);
actions.push(mapScopeNodeToAction(node, selectScope, parentId || undefined, parentName || undefined));
}
setActions(actions);
});
} else {
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, getScopeNodes]);
return actions;
}