Scopes: Command Palette (#103720)

* Add pills in search bar for context

* Add scope actions

* Add selection functionality

* Show selected scope on secondary row

* Fix selected scope titles

* Add some basic tests

* Test for toggle by name

* Remove unnecessary mocking

* Small cleanups

* Lint fixes

* Fix test

* Update public/app/features/scopes/selector/ScopesSelectorService.ts

Co-authored-by: Tobias Skarhed <1438972+tskarhed@users.noreply.github.com>

* Bump input and breadcrumbs test

* Change breadcrumbs color

* Makes the breacrumb spacing consistent

---------

Co-authored-by: Tobias Skarhed <1438972+tskarhed@users.noreply.github.com>
This commit is contained in:
Andrej Ocenas
2025-05-05 14:00:20 +02:00
committed by GitHub
co-authored by Tobias Skarhed
parent d4fd3aa7c7
commit 8435eb2876
17 changed files with 910 additions and 145 deletions
@@ -2,17 +2,8 @@ import { css, cx } from '@emotion/css';
import { useDialog } from '@react-aria/dialog';
import { FocusScope } from '@react-aria/focus';
import { useOverlay } from '@react-aria/overlays';
import {
KBarAnimator,
KBarPortal,
KBarPositioner,
KBarSearch,
VisualState,
useRegisterActions,
useKBar,
ActionImpl,
} from 'kbar';
import { useEffect, useMemo, useRef } from 'react';
import { KBarAnimator, KBarPortal, KBarPositioner, VisualState, useKBar, ActionImpl } from 'kbar';
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
@@ -21,23 +12,49 @@ import { EmptyState, Icon, LoadingBar, useStyles2 } from '@grafana/ui';
import { t } from 'app/core/internationalization';
import { KBarResults } from './KBarResults';
import { KBarSearch } from './KBarSearch';
import { ResultItem } from './ResultItem';
import { useSearchResults } from './actions/dashboardActions';
import useActions from './actions/useActions';
import {
useRegisterRecentDashboardsActions,
useRegisterRecentScopesActions,
useRegisterScopesActions,
useRegisterStaticActions,
} from './actions/useActions';
import { CommandPaletteAction } from './types';
import { useMatches } from './useMatches';
export function CommandPalette() {
useRegisterStaticActions();
return (
<KBarPortal>
<CommandPaletteContents />
</KBarPortal>
);
}
/**
* Actual contents of the command palette. As KBarPortal controls the mount of this component this is split so that
* we can run code only after command palette is opened.
* @constructor
*/
function CommandPaletteContents() {
const lateralSpace = getCommandPalettePosition();
const styles = useStyles2(getSearchStyles, lateralSpace);
const { query, showing, searchQuery } = useKBar((state) => ({
const { query, showing, searchQuery, currentRootActionId } = useKBar((state) => ({
showing: state.visualState === VisualState.showing,
searchQuery: state.searchQuery,
currentRootActionId: state.currentRootActionId,
}));
const actions = useActions(searchQuery);
useRegisterActions(actions, [actions]);
useRegisterRecentDashboardsActions(searchQuery);
useRegisterRecentScopesActions();
const queryToggle = useCallback(() => query.toggle(), [query]);
const { scopesRow } = useRegisterScopesActions(searchQuery, queryToggle, currentRootActionId);
// Dashboards and folders
const { searchResults, isFetchingSearchResults } = useSearchResults(searchQuery, showing);
const ref = useRef<HTMLDivElement>(null);
@@ -50,34 +67,65 @@ export function CommandPalette() {
// Report interaction when opened
useEffect(() => {
showing && reportInteraction('command_palette_opened');
}, [showing]);
reportInteraction('command_palette_opened');
}, []);
return actions.length > 0 ? (
<KBarPortal>
<KBarPositioner className={styles.positioner}>
<KBarAnimator className={styles.animator}>
<FocusScope contain autoFocus restoreFocus>
<div {...overlayProps} {...dialogProps}>
<div className={styles.searchContainer}>
<Icon name="search" size="md" />
<KBarSearch
defaultPlaceholder={t('command-palette.search-box.placeholder', 'Search or jump to...')}
className={styles.search}
/>
<div className={styles.loadingBarContainer}>
{isFetchingSearchResults && <LoadingBar width={500} delay={0} />}
</div>
</div>
<div className={styles.resultsContainer}>
<RenderResults isFetchingSearchResults={isFetchingSearchResults} searchResults={searchResults} />
return (
<KBarPositioner className={styles.positioner}>
<KBarAnimator className={styles.animator}>
<FocusScope contain autoFocus restoreFocus>
<div {...overlayProps} {...dialogProps}>
<div className={styles.searchContainer}>
<Icon name="search" size="md" className={styles.searchIcon} />
<AncestorBreadcrumbs />
<KBarSearch
defaultPlaceholder={t('command-palette.search-box.placeholder', 'Search or jump to...')}
className={styles.search}
/>
<div className={styles.loadingBarContainer}>
{isFetchingSearchResults && <LoadingBar width={500} delay={0} />}
</div>
</div>
</FocusScope>
</KBarAnimator>
</KBarPositioner>
</KBarPortal>
) : null;
{scopesRow ? <div className={styles.searchContainer}>{scopesRow}</div> : null}
<div className={styles.resultsContainer}>
<RenderResults isFetchingSearchResults={isFetchingSearchResults} searchResults={searchResults} />
</div>
</div>
</FocusScope>
</KBarAnimator>
</KBarPositioner>
);
}
/**
* Breadcrumbs for selected actions or categories in the command palette. This has to be a separate component
* from the one that is registering actions because we need actions prop from kbar and doing both in the same component
* creates rerender loop.
* @constructor
*/
function AncestorBreadcrumbs() {
const lateralSpace = getCommandPalettePosition();
const styles = useStyles2(getSearchStyles, lateralSpace);
const { actions, currentRootActionId } = useKBar((state) => ({
actions: state.actions,
currentRootActionId: state.currentRootActionId,
}));
// To show breadcrumbs of actions selected if they are nested
const ancestorActions = currentRootActionId
? [...actions[currentRootActionId].ancestors, actions[currentRootActionId]]
: [];
return (
ancestorActions.length > 0 && (
<span className={styles.breadcrumbs}>
{ancestorActions.map((action, index) => (
<React.Fragment key={action.id || index}>{action.name}&nbsp;/&nbsp;</React.Fragment>
))}
</span>
)
);
}
interface RenderResultsProps {
@@ -204,9 +252,9 @@ const getSearchStyles = (theme: GrafanaTheme2, lateralSpace: number) => {
background: theme.components.input.background,
borderBottom: `1px solid ${theme.colors.border.weak}`,
display: 'flex',
gap: theme.spacing(1),
padding: theme.spacing(1, 2),
position: 'relative',
justifyContent: 'space-between',
}),
search: css({
fontSize: theme.typography.fontSize,
@@ -235,5 +283,40 @@ const getSearchStyles = (theme: GrafanaTheme2, lateralSpace: number) => {
borderTop: 'none',
marginTop: 0,
}),
breadcrumbs: css({
label: 'breadcrumbs',
fontSize: theme.typography.body.fontSize,
fontWeight: theme.typography.fontWeightMedium,
lineHeight: theme.typography.body.lineHeight,
color: theme.colors.text.primary,
display: 'flex',
alignItems: 'center',
whiteSpace: 'nowrap',
}),
scopesText: css({
label: 'scopesText',
fontSize: theme.typography.bodySmall.fontSize,
fontWeight: theme.typography.fontWeightMedium,
lineHeight: theme.typography.bodySmall.lineHeight,
color: theme.colors.text.secondary,
}),
searchIcon: css({
marginRight: theme.spacing(1),
}),
selectedScope: css({
background: theme.colors.background.secondary,
borderRadius: theme.shape.radius.default,
padding: theme.spacing(0, 0.5),
fontSize: theme.typography.bodySmall.fontSize,
fontWeight: theme.typography.fontWeightMedium,
lineHeight: theme.typography.bodySmall.lineHeight,
color: theme.colors.text.secondary,
display: 'inline-flex',
alignItems: 'center',
position: 'relative',
border: `1px solid ${theme.colors.background.secondary}`,
whiteSpace: 'nowrap',
marginRight: theme.spacing(0.5),
}),
};
};
@@ -71,7 +71,7 @@ export const KBarResults = (props: KBarResultsProps) => {
}
return nextIndex;
});
} else if (event.key === 'Enter') {
} else if (event.key === 'Enter' && !event.metaKey) {
event.preventDefault();
// storing the active dom element in a ref prevents us from
// having to calculate the current action to perform based
@@ -122,7 +122,10 @@ export const KBarResults = (props: KBarResultsProps) => {
if (item.command) {
item.command.perform(item);
query.toggle();
// TODO: ideally the perform method would return some marker or we would have something like preventDefault()
if (!item.id.startsWith('scopes/') || item.id === 'scopes/apply') {
query.toggle();
}
} else if (url) {
if (!(ev.ctrlKey || ev.metaKey || ev.shiftKey)) {
query.toggle();
@@ -0,0 +1,86 @@
import { css } from '@emotion/css';
import { useKBar, VisualState } from 'kbar';
import * as React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
export const KBAR_LISTBOX = 'kbar-listbox';
export const getListboxItemId = (id: number) => `kbar-listbox-item-${id}`;
export function KBarSearch(
props: React.InputHTMLAttributes<HTMLInputElement> & {
defaultPlaceholder?: string;
}
) {
const { query, search, actions, currentRootActionId, activeIndex, showing, options } = useKBar((state) => ({
search: state.searchQuery,
currentRootActionId: state.currentRootActionId,
actions: state.actions,
activeIndex: state.activeIndex,
showing: state.visualState === VisualState.showing,
}));
const [inputValue, setInputValue] = React.useState(search);
React.useEffect(() => {
query.setSearch(inputValue);
}, [inputValue, query]);
const { defaultPlaceholder, ...rest } = props;
React.useEffect(() => {
query.setSearch('');
query.getInput().focus();
setInputValue('');
return () => query.setSearch('');
}, [currentRootActionId, query]);
const defaultText = defaultPlaceholder ?? 'Type a command or search…';
const styles = useStyles2(getStyles);
return (
<input
{...rest}
className={styles.input}
ref={query.inputRefSetter}
/* eslint-disable-next-line jsx-a11y/no-autofocus */
autoFocus
autoComplete="off"
role="combobox"
spellCheck="false"
aria-expanded={showing}
aria-controls={KBAR_LISTBOX}
aria-activedescendant={getListboxItemId(activeIndex)}
value={inputValue}
placeholder={defaultText}
onChange={(event) => {
props.onChange?.(event);
setInputValue(event.target.value);
options?.callbacks?.onQueryChange?.(event.target.value);
}}
onKeyDown={(event) => {
props.onKeyDown?.(event);
if (currentRootActionId && !search && event.key === 'Backspace') {
const parent = actions[currentRootActionId].parent;
query.setCurrentRootAction(parent);
}
}}
/>
);
}
const getStyles = (theme: GrafanaTheme2) => {
return {
input: css({
label: 'kbar-search-input',
fontSize: theme.typography.body.fontSize,
fontWeight: theme.typography.fontWeightMedium,
lineHeight: theme.typography.body.lineHeight,
color: theme.colors.text.secondary,
width: '100%',
outline: 'none',
paddingLeft: 0,
}),
};
};
@@ -0,0 +1,82 @@
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { Button, FilterPill, Stack, Text, useStyles2 } from '@grafana/ui';
import { Trans } from '../../core/internationalization';
import { getModKey } from '../../core/utils/browser';
import { ToggleNode, TreeScope } from '../scopes/selector/types';
type Props = {
treeScopes: TreeScope[];
isDirty: boolean;
apply: () => void;
toggleNode: (node: ToggleNode) => void;
};
/**
* Shows scopes that are already selected and applied or the ones user just selected in the palette, with an apply
* button if the selection is dirty.
*/
export function ScopesRow({ treeScopes, isDirty, apply, toggleNode }: Props) {
const styles = useStyles2(getStyles);
return (
<>
<Stack alignItems={'center'}>
<span className={styles.scopesText}>
<Trans i18nKey={'command-palette.scopes.selected-scopes-label'}>Scopes: </Trans>
</span>
{treeScopes?.map((scope) => {
return (
<FilterPill
key={scope.scopeName}
selected={true}
icon={'times'}
label={scope.title}
onClick={() => {
toggleNode(scope);
}}
/>
);
})}
</Stack>
{isDirty && (
<Button
onClick={() => {
apply();
}}
>
<Trans i18nKey={'command-palette.scopes.apply-selected-scopes'}>Apply</Trans>&nbsp;
<Text variant="bodySmall">{`${getModKey()}+↵`}</Text>
</Button>
)}
</>
);
}
const getStyles = (theme: GrafanaTheme2) => {
return {
scopesText: css({
label: 'scopesText',
fontSize: theme.typography.bodySmall.fontSize,
fontWeight: theme.typography.fontWeightMedium,
lineHeight: theme.typography.bodySmall.lineHeight,
color: theme.colors.text.secondary,
}),
selectedScope: css({
background: theme.colors.background.secondary,
borderRadius: theme.shape.radius.default,
padding: theme.spacing(0, 0.5),
fontSize: theme.typography.bodySmall.fontSize,
fontWeight: theme.typography.fontWeightMedium,
lineHeight: theme.typography.bodySmall.lineHeight,
color: theme.colors.text.secondary,
display: 'inline-flex',
alignItems: 'center',
position: 'relative',
border: `1px solid ${theme.colors.background.secondary}`,
whiteSpace: 'nowrap',
marginRight: theme.spacing(0.5),
}),
};
};
@@ -79,6 +79,11 @@ export async function getSearchResultActions(searchQuery: string): Promise<Comma
return goToSearchResultActions;
}
/**
* Implements actual search logic for dashboards and folders.
* @param searchQuery
* @param isShowing
*/
export function useSearchResults(searchQuery: string, isShowing: boolean) {
const [searchResults, setSearchResults] = useState<CommandPaletteAction[]>([]);
const [isFetchingSearchResults, setIsFetchingSearchResults] = useState(false);
@@ -1,17 +1,18 @@
import { config } from '@grafana/runtime';
import { t } from 'app/core/internationalization';
import { defaultScopesServices } from 'app/features/scopes/ScopesContextProvider';
import { useScopesServices } from 'app/features/scopes/ScopesContextProvider';
import { CommandPaletteAction } from '../types';
import { RECENT_SCOPES_PRIORITY } from '../values';
export function getRecentScopesActions(): CommandPaletteAction[] {
if (!config.featureToggles.scopeFilters) {
const services = useScopesServices();
if (!(config.featureToggles.scopeFilters && services)) {
return [];
}
const { scopesSelectorService } = defaultScopesServices();
const { scopesSelectorService } = services;
const recentScopes = scopesSelectorService.getRecentScopes();
return recentScopes.map((recentScope) => {
@@ -1,9 +1,12 @@
import { useMemo } from 'react';
import { NavModelItem } from '@grafana/data';
import { enrichHelpItem } from 'app/core/components/AppChrome/MegaMenu/utils';
import { performInviteUserClick, shouldRenderInviteUserButton } from 'app/core/components/InviteUserButton/utils';
import { t } from 'app/core/internationalization';
import { changeTheme } from 'app/core/services/theme';
import { useSelector } from '../../../types';
import { CommandPaletteAction } from '../types';
import { ACTIONS_PRIORITY, DEFAULT_PRIORITY, PREFERENCES_PRIORITY } from '../values';
@@ -71,11 +74,11 @@ function navTreeToActions(navTree: NavModelItem[], parents: NavModelItem[] = [])
return navActions;
}
export default (navBarTree: NavModelItem[], extensionActions: CommandPaletteAction[]): CommandPaletteAction[] => {
const globalActions: CommandPaletteAction[] = [
function getGlobalActions(): CommandPaletteAction[] {
return [
{
id: 'preferences/theme',
name: t('command-palette.action.change-theme', 'Change theme...'),
name: t('command-palette.action.change-theme', 'Change theme'),
keywords: 'interface color dark light',
section: t('command-palette.section.preferences', 'Preferences'),
priority: PREFERENCES_PRIORITY,
@@ -97,20 +100,24 @@ export default (navBarTree: NavModelItem[], extensionActions: CommandPaletteActi
priority: PREFERENCES_PRIORITY,
},
];
}
const navBarActions = navTreeToActions(navBarTree);
export function useStaticActions(): CommandPaletteAction[] {
const navBarTree = useSelector((state) => state.navBarTree);
return useMemo(() => {
const navBarActions = navTreeToActions(navBarTree);
if (shouldRenderInviteUserButton) {
navBarActions.push({
id: 'invite-user',
name: t('navigation.invite-user.invite-new-member-button', 'Invite new member'),
section: t('command-palette.section.actions', 'Actions'),
priority: ACTIONS_PRIORITY,
perform: () => {
performInviteUserClick('command_palette_actions', 'invite-user-command-palette');
},
});
}
return [...globalActions, ...extensionActions, ...navBarActions];
};
if (shouldRenderInviteUserButton) {
navBarActions.push({
id: 'invite-user',
name: t('navigation.invite-user.invite-new-member-button', 'Invite new member'),
section: t('command-palette.section.actions', 'Actions'),
priority: ACTIONS_PRIORITY,
perform: () => {
performInviteUserClick('command_palette_actions', 'invite-user-command-palette');
},
});
}
return [...getGlobalActions(), ...navBarActions];
}, [navBarTree]);
}
@@ -1,38 +0,0 @@
import { useEffect, useState } from 'react';
import { useSelector } from 'app/types';
import { CommandPaletteAction } from '../types';
import { getRecentDashboardActions } from './dashboardActions';
import { getRecentScopesActions } from './recentScopesActions';
import getStaticActions from './staticActions';
import useExtensionActions from './useExtensionActions';
export default function useActions(searchQuery: string) {
const [navTreeActions, setNavTreeActions] = useState<CommandPaletteAction[]>([]);
const [recentDashboardActions, setRecentDashboardActions] = useState<CommandPaletteAction[]>([]);
const extensionActions = useExtensionActions();
const navBarTree = useSelector((state) => state.navBarTree);
const recentScopesActions = getRecentScopesActions();
// Load standard static actions
useEffect(() => {
const staticActionsResp = getStaticActions(navBarTree, extensionActions);
setNavTreeActions(staticActionsResp);
}, [navBarTree, extensionActions]);
// Load recent dashboards - we don't want them to reload when the nav tree changes
useEffect(() => {
if (!searchQuery) {
getRecentDashboardActions()
.then((recentDashboardActions) => setRecentDashboardActions(recentDashboardActions))
.catch((err) => {
console.error('Error loading recent dashboard actions', err);
});
}
}, [searchQuery]);
return searchQuery ? navTreeActions : [...recentDashboardActions, ...navTreeActions, ...recentScopesActions];
}
@@ -0,0 +1,194 @@
import { useRegisterActions } from 'kbar';
import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
import { useObservable } from 'react-use';
import { Observable } from 'rxjs';
import { config } from '@grafana/runtime';
import { t } from '../../../core/internationalization';
import { useScopesServices } from '../../scopes/ScopesContextProvider';
import { ScopesSelectorServiceState } from '../../scopes/selector/ScopesSelectorService';
import { NodesMap, Node, TreeScope, ToggleNode } 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';
/**
* Register navigation actions to different parts of grafana or some preferences stuff like themes.
*/
export function useRegisterStaticActions() {
const extensionActions = useExtensionActions();
const staticActions = useStaticActions();
const navTreeActions = useMemo(() => {
return [...staticActions, ...extensionActions];
}, [staticActions, extensionActions]);
useRegisterActions(navTreeActions, [navTreeActions]);
}
export function useRegisterRecentDashboardsActions(searchQuery: string) {
const [recentDashboardActions, setRecentDashboardActions] = useState<CommandPaletteAction[]>([]);
useEffect(() => {
if (!searchQuery) {
getRecentDashboardActions()
.then((recentDashboardActions) => setRecentDashboardActions(recentDashboardActions))
.catch((err) => {
console.error('Error loading recent dashboard actions', err);
});
}
}, [searchQuery]);
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, toggleNodeSelect, apply, resetSelection } = services.scopesSelectorService;
// Initialize the scopes first time this runs and reset the scopes that were selected on unmount.
useEffect(() => {
updateNode([''], true, '');
return () => {
resetSelection();
};
}, [updateNode, resetSelection]);
// Load next level of scopes when the parentId changes.
useEffect(() => {
updateNode(getScopePathFromActionId(parentId), true, searchQuery);
}, [updateNode, searchQuery, parentId]);
const selectorServiceState: ScopesSelectorServiceState | undefined = useObservable(
services.scopesSelectorService.stateObservable ?? new Observable(),
services.scopesSelectorService.state
);
const { nodes, loading, loadingNodeName, treeScopes, selectedScopes } = selectorServiceState;
const nodesActions = mapScopeNodesToActions(nodes, treeScopes, toggleNodeSelect);
// Other types can use the actions themselves as a dependency to prevent registering every time the hook runs. The
// scopes tree though is loaded on demand, and it would be a deep check to see if something changes these deps are
// approximation of when the actions really change.
useRegisterActions(nodesActions, [parentId, loading, loadingNodeName, treeScopes]);
const isDirty =
treeScopes
.map((t) => t.scopeName)
.sort()
.join('') !==
selectedScopes
.map((s) => s.scope.metadata.name)
.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 || treeScopes?.length ? (
<ScopesRow toggleNode={toggleNodeSelect} treeScopes={treeScopes} apply={finalApply} isDirty={isDirty} />
) : null,
};
}
function mapScopeNodesToActions(
nodes: NodesMap,
selectedScopes: TreeScope[],
toggleNodeSelect: (node: ToggleNode) => void
) {
const actions: CommandPaletteAction[] = [
{
id: 'scopes',
section: t('command-palette.action.scopes', 'Scopes'),
name: t('command-palette.action.scopes', 'Scopes'),
keywords: 'scopes filters',
priority: SCOPES_PRIORITY,
},
];
const traverse = (node: Node, parentId: string) => {
// TODO: not sure how and why a node.nodes can be undefined
if (!node.nodes || Object.keys(node.nodes).length === 0) {
return;
}
for (const key of Object.keys(node.nodes)) {
const child = node.nodes[key];
// Selected scopes are not shown in the list but in separate section
if (child.nodeType === 'leaf') {
if (selectedScopes.map((s) => s.scopeName).includes(child.linkId!)) {
continue;
}
}
const action: CommandPaletteAction = {
id: `${parentId}/${child.name}`,
name: child.title,
keywords: `${child.title} ${child.name}`,
priority: SCOPES_PRIORITY,
parent: parentId,
};
if (child.nodeType === 'leaf') {
action.perform = () => {
toggleNodeSelect({ scopeName: child.name, path: getScopePathFromActionId(action.id) });
};
}
actions.push(action);
traverse(child, action.id);
}
};
traverse(nodes[''], 'scopes');
return actions;
}
function getScopePathFromActionId(id?: string | null) {
// The root action has id scopes while in the selectorService tree the root id = ''
return id?.replace('scopes', '').split('/') ?? [''];
}
@@ -1,3 +1,4 @@
export const SCOPES_PRIORITY = 8;
export const RECENT_SCOPES_PRIORITY = 7;
export const RECENT_DASHBOARDS_PRIORITY = 6;
export const ACTIONS_PRIORITY = 5;
@@ -0,0 +1,266 @@
import { Scope } from '@grafana/data';
import { ScopesApiClient } from '../ScopesApiClient';
import { ScopesDashboardsService } from '../dashboards/ScopesDashboardsService';
import { ScopesSelectorService } from './ScopesSelectorService';
import { Node, NodeReason, NodesMap } from './types';
describe('ScopesSelectorService', () => {
let service: ScopesSelectorService;
let apiClient: jest.Mocked<ScopesApiClient>;
let dashboardsService: jest.Mocked<ScopesDashboardsService>;
const mockScope: Scope = {
metadata: {
name: 'test-scope',
},
spec: {
title: 'test-scope',
type: 'scope',
description: 'test scope',
category: 'scope',
filters: [],
},
};
const mockNode: Node = {
name: 'test-scope',
title: 'Test Node',
reason: NodeReason.Result,
nodeType: 'container',
expandable: true,
selectable: false,
expanded: false,
query: '',
nodes: {},
};
const mockNodesMap: NodesMap = {
'': mockNode,
};
beforeEach(() => {
apiClient = {
fetchScope: jest.fn().mockResolvedValue(mockScope),
fetchMultipleScopes: jest.fn().mockResolvedValue([{ scope: mockScope, path: ['', 'test-scope'] }]),
fetchNode: jest.fn().mockResolvedValue(mockNodesMap),
fetchDashboards: jest.fn().mockResolvedValue([]),
fetchScopeNavigations: jest.fn().mockResolvedValue([]),
} as unknown as jest.Mocked<ScopesApiClient>;
dashboardsService = {
fetchDashboards: jest.fn(),
} as unknown as jest.Mocked<ScopesDashboardsService>;
service = new ScopesSelectorService(apiClient, dashboardsService);
});
describe('updateNode', () => {
it('should update node and fetch children when expanded', async () => {
await service.updateNode([''], true, '');
expect(apiClient.fetchNode).toHaveBeenCalledWith('', '');
expect(service.state.nodes[''].expanded).toBe(true);
});
it('should update node query and fetch children when query changes', async () => {
await service.updateNode([''], false, 'new-query');
expect(apiClient.fetchNode).toHaveBeenCalledWith('', 'new-query');
});
it('should not fetch children when node is collapsed and query is unchanged', async () => {
// First expand the node
await service.updateNode([''], true, '');
// Then collapse it
await service.updateNode([''], false, '');
// fetchNode should be called only once (for the expansion)
expect(apiClient.fetchNode).toHaveBeenCalledTimes(1);
});
});
describe('toggleNodeSelect', () => {
it('should select a node when it is not selected', async () => {
await service.updateNode([''], true, '');
const rootNode = service.state.nodes[''];
rootNode.nodes['test-scope'] = {
...mockNode,
selectable: true,
linkId: 'test-scope',
};
service.toggleNodeSelect({ path: ['', 'test-scope'] });
expect(service.state.treeScopes).toEqual([
{
scopeName: 'test-scope',
path: ['', 'test-scope'],
title: 'Test Node',
},
]);
expect(apiClient.fetchScope).toHaveBeenCalledWith('test-scope');
});
it('should deselect a node when it is already selected', async () => {
await service.updateNode([''], true, '');
const rootNode = service.state.nodes[''];
rootNode.nodes['test-scope'] = {
...mockNode,
selectable: true,
linkId: 'test-scope',
};
// Select the node
service.toggleNodeSelect({ path: ['', 'test-scope'] });
// Deselect the node
service.toggleNodeSelect({ path: ['', 'test-scope'] });
expect(service.state.treeScopes).toEqual([]);
});
it('should deselect a node by name', async () => {
// Make the scope selected and applied
await service.changeScopes(['test-scope']);
// Deselect the node
service.toggleNodeSelect({ scopeName: 'test-scope' });
expect(service.state.treeScopes).toEqual([]);
});
});
describe('changeScopes', () => {
it('should update treeScopes with the provided scope names', () => {
service.changeScopes(['test-scope']);
expect(service.state.treeScopes).toEqual([
{
scopeName: 'test-scope',
path: [],
title: 'test-scope',
},
]);
});
});
describe('open', () => {
it('should open the selector and load root nodes if not loaded', async () => {
await service.open();
expect(service.state.opened).toBe(true);
});
it('should not reload root nodes if already loaded', async () => {
// First load the nodes
await service.updateNode([''], true, '');
// Reset the mock to check if it's called again
apiClient.fetchNode.mockClear();
// Open the selector
await service.open();
expect(service.state.opened).toBe(true);
});
});
describe('closeAndReset', () => {
it('should close the selector and reset treeScopes to match selectedScopes', async () => {
// Setup: Open the selector and select a scope
await service.open();
await service.changeScopes(['test-scope']);
service.closeAndReset();
expect(service.state.opened).toBe(false);
expect(service.state.treeScopes).toEqual([
{
scopeName: 'test-scope',
path: ['', 'test-scope'],
title: 'test-scope',
},
]);
});
});
describe('closeAndApply', () => {
it('should close the selector and apply the selected scopes', async () => {
await service.open();
const rootNode = service.state.nodes[''];
rootNode.nodes['test-scope'] = {
...mockNode,
selectable: true,
linkId: 'test-scope',
};
service.toggleNodeSelect({ path: ['', 'test-scope'] });
await service.closeAndApply();
expect(service.state.opened).toBe(false);
expect(dashboardsService.fetchDashboards).toHaveBeenCalledWith(['test-scope']);
});
});
describe('apply', () => {
it('should apply the selected scopes without closing the selector', async () => {
await service.open();
const rootNode = service.state.nodes[''];
rootNode.nodes['test-scope'] = {
...mockNode,
selectable: true,
linkId: 'test-scope',
};
service.toggleNodeSelect({ path: ['', 'test-scope'] });
await service.apply();
expect(service.state.opened).toBe(true);
expect(dashboardsService.fetchDashboards).toHaveBeenCalledWith(['test-scope']);
});
});
describe('resetSelection', () => {
it('should reset treeScopes to match selectedScopes', async () => {
await service.open();
await service.changeScopes(['test-scope']);
service.resetSelection();
expect(service.state.treeScopes).toEqual([
{
scopeName: 'test-scope',
path: ['', 'test-scope'],
title: 'test-scope',
},
]);
});
});
describe('removeAllScopes', () => {
it('should remove all selected scopes', async () => {
await service.open();
const rootNode = service.state.nodes[''];
rootNode.nodes['test-scope'] = {
...mockNode,
selectable: true,
linkId: 'test-scope',
};
service.toggleNodeSelect({ path: ['', 'test-scope'] });
await service.apply();
await service.removeAllScopes();
expect(service.state.selectedScopes).toEqual([]);
expect(service.state.treeScopes).toEqual([]);
});
});
});
@@ -5,7 +5,7 @@ import { ScopesServiceBase } from '../ScopesServiceBase';
import { ScopesDashboardsService } from '../dashboards/ScopesDashboardsService';
import { getEmptyScopeObject } from '../utils';
import { NodeReason, NodesMap, SelectedScope, TreeScope } from './types';
import { Node, NodeReason, NodesMap, SelectedScope, ToggleNode, TreeScope } from './types';
const RECENT_SCOPES_KEY = 'grafana.scopes.recent';
@@ -67,16 +67,23 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
// Making a copy as we will be changing this in place and then updating state later.
// This though does not make a deep copy so you cannot rely on reference of nested nodes changing.
const nodes = { ...this.state.nodes };
let currentLevel: NodesMap = nodes;
let parentNode: Node | undefined = nodes[''];
let loadingNodeName = path[0];
let currentNode = nodes[''];
if (path.length > 1) {
const pathToParent = path.slice(0, path.length - 1);
currentLevel = getNodesAtPath(nodes, pathToParent);
const pathToParent = path.slice(1, path.length - 1);
parentNode = getNodesAtPath(nodes[''], pathToParent);
loadingNodeName = last(path)!;
if (!parentNode) {
console.warn('No parent node found for path:', path);
return;
}
currentNode = parentNode.nodes[loadingNodeName];
}
const currentNode = currentLevel[loadingNodeName];
const differentQuery = currentNode.query !== query;
currentNode.expanded = expanded;
@@ -125,47 +132,78 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
*
* The main function of this method is to update the treeScopes state which is a representation of what is selected in
* the UI and to prefetch the scope data from the server.
* @param path
*/
public toggleNodeSelect = (path: string[]) => {
let treeScopes = [...this.state.treeScopes];
public toggleNodeSelect = (node: ToggleNode) => {
if ('scopeName' in node) {
// This is for a case where we don't have a path yet. For example on init we get the selected from url, but
// just the names. If we want to deselect them without knowing where in the tree they are we can just pass the
// name.
let parentNode = this.state.nodes[''];
for (let idx = 1; idx < path.length - 1; idx++) {
parentNode = parentNode.nodes[path[idx]];
const newTreeScopes = this.state.treeScopes.filter((s) => s.scopeName !== node.scopeName);
if (newTreeScopes.length !== this.state.treeScopes.length) {
this.updateState({ treeScopes: newTreeScopes });
return;
}
}
const nodeName = path[path.length - 1];
const { linkId } = parentNode.nodes[nodeName];
if (!node.path) {
console.warn('Node cannot be selected without both path and name', node);
return;
}
let treeScopes = [...this.state.treeScopes];
const parentNode = getNodesAtPath(this.state.nodes[''], node.path.slice(1, -1));
if (!parentNode) {
// Either the path is wrong or we don't have the nodes loaded yet. So let's check the selected tree nodes if we
// can remove something based on scope name.
const scopeName = node.path.at(-1);
const newTreeScopes = treeScopes.filter((s) => s.scopeName !== scopeName);
if (newTreeScopes.length !== treeScopes.length) {
this.updateState({ treeScopes: newTreeScopes });
} else {
console.warn('No node found for path:', node.path);
}
return;
}
const nodeName = node.path[node.path.length - 1];
const { linkId, title } = parentNode.nodes[nodeName];
const selectedIdx = treeScopes.findIndex(({ scopeName }) => scopeName === linkId);
if (selectedIdx === -1) {
// We are selecting a new node.
// We prefetch the scope when clicking on it. This will mean that once the selection is applied in closeAndApply()
// we already have all the scopes in cache and don't need to fetch all of them again is multiple requests.
this.apiClient.fetchScope(linkId!);
const treeScope: TreeScope = {
scopeName: linkId!,
path: node.path,
title,
};
// We cannot select multiple scopes with different parents only. In that case we will just deselect all the
// others.
const selectedFromSameNode =
treeScopes.length === 0 ||
Object.values(parentNode.nodes).some(({ linkId }) => linkId === treeScopes[0].scopeName);
const treeScope = {
scopeName: linkId!,
path,
};
this.updateState({
treeScopes: parentNode?.disableMultiSelect || !selectedFromSameNode ? [treeScope] : [...treeScopes, treeScope],
});
} else {
// We are deselecting already selected node.
treeScopes.splice(selectedIdx, 1);
this.updateState({ treeScopes });
}
};
changeScopes = (scopeNames: string[]) => this.setNewScopes(scopeNames.map((scopeName) => ({ scopeName, path: [] })));
changeScopes = (scopeNames: string[]) => {
return this.setNewScopes(scopeNames.map((scopeName) => ({ scopeName, path: [], title: scopeName })));
};
/**
* Apply the selected scopes. Apart from setting the scopes it also fetches the scope metadata and also loads the
@@ -178,8 +216,8 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
return;
}
let selectedScopes = treeScopes.map(({ scopeName, path }) => ({
scope: getEmptyScopeObject(scopeName),
let selectedScopes = treeScopes.map(({ scopeName, path, title }) => ({
scope: getEmptyScopeObject(scopeName, title),
path,
}));
@@ -189,11 +227,26 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
// Fetches both dashboards and scope navigations
this.dashboardsService.fetchDashboards(selectedScopes.map(({ scope }) => scope.metadata.name));
selectedScopes = await this.apiClient.fetchMultipleScopes(treeScopes);
if (selectedScopes.length > 0) {
this.addRecentScopes(selectedScopes);
if (treeScopes.length > 0) {
selectedScopes = await this.apiClient.fetchMultipleScopes(treeScopes);
if (selectedScopes.length > 0) {
this.addRecentScopes(selectedScopes);
}
}
this.updateState({ selectedScopes, loading: false });
// Make sure the treeScopes also have the right title as we use it to display the selection in the UI while to set
// the scopes you just need the name/id.
const updatedTreeScopes = treeScopes.map((treeScope) => {
const matchingSelectedScope = selectedScopes.find(
(selectedScope) => selectedScope.scope.metadata.name === treeScope.scopeName
);
return {
...treeScope,
title: matchingSelectedScope?.scope.spec.title || treeScope.title,
};
});
this.updateState({ selectedScopes, treeScopes: updatedTreeScopes, loading: false });
};
public removeAllScopes = () => this.setNewScopes([]);
@@ -255,7 +308,15 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
public closeAndApply = () => {
this.updateState({ opened: false });
this.setNewScopes();
return this.apply();
};
public apply = () => {
return this.setNewScopes();
};
public resetSelection = () => {
this.updateState({ treeScopes: getTreeScopesFromSelectedScopes(this.state.selectedScopes) });
};
}
@@ -279,6 +340,7 @@ function getTreeScopesFromSelectedScopes(scopes: SelectedScope[]): TreeScope[] {
return scopes.map(({ scope, path }) => ({
scopeName: scope.metadata.name,
path,
title: scope.spec.title,
}));
}
@@ -352,12 +414,15 @@ function expandNodes(nodes: NodesMap, path: string[]): NodesMap {
return nodes;
}
function getNodesAtPath(nodes: NodesMap, path: string[]): NodesMap {
let currentNodes = nodes;
function getNodesAtPath(node: Node, path: string[]): Node | undefined {
let currentNode = node;
for (const section of path) {
currentNodes = currentNodes[section].nodes;
if (currentNode === undefined) {
return undefined;
}
currentNode = currentNode.nodes[section];
}
return currentNodes;
return currentNode;
}
@@ -75,7 +75,7 @@ export function ScopesTreeItem({
label=""
data-testid={`scopes-tree-${type}-${childNode.name}-radio`}
onClick={() => {
onNodeSelectToggle(childNodePath);
onNodeSelectToggle({ path: childNodePath });
}}
/>
) : (
@@ -83,7 +83,7 @@ export function ScopesTreeItem({
checked={selected}
data-testid={`scopes-tree-${type}-${childNode.name}-checkbox`}
onChange={() => {
onNodeSelectToggle(childNodePath);
onNodeSelectToggle({ path: childNodePath });
}}
/>
)
+5 -1
View File
@@ -23,9 +23,13 @@ export interface SelectedScope {
}
export interface TreeScope {
title: string;
scopeName: string;
path: string[];
}
// Sort of partial treeScope that is used as a way to say which node should be toggled.
export type ToggleNode = { scopeName: string; path?: string[] } | { path: string[]; scopeName?: string };
export type OnNodeUpdate = (path: string[], expanded: boolean, query: string) => void;
export type OnNodeSelectToggle = (path: string[]) => void;
export type OnNodeSelectToggle = (node: ToggleNode) => void;
@@ -96,13 +96,14 @@ describe('Selector', () => {
await selectResultApplicationsMimir();
await applyScopes();
// Grafana,Mimir currently selected. Grafana is the first recent scope.
// recent scopes only show on top level, so we need to make sure the scopes tree is not exapnded.
await clearSelector();
await openSelector();
expectRecentScopesSection();
await expandRecentScopes();
expectRecentScope('Grafana');
expectRecentScopeNotPresent('Mimir');
expectRecentScopeNotPresent('Grafana, Mimir');
expectRecentScope('Grafana, Mimir');
await selectRecentScope('Grafana');
expectScopesSelectorValue('Grafana');
+2 -2
View File
@@ -1,11 +1,11 @@
import { Scope } from '@grafana/data';
export function getEmptyScopeObject(name: string): Scope {
export function getEmptyScopeObject(name: string, title?: string): Scope {
return {
metadata: { name },
spec: {
filters: [],
title: name,
title: title || name,
type: '',
category: '',
description: '',
+7 -2
View File
@@ -2785,13 +2785,18 @@
},
"command-palette": {
"action": {
"change-theme": "Change theme...",
"change-theme": "Change theme",
"dark-theme": "Dark",
"light-theme": "Light"
"light-theme": "Light",
"scopes": "Scopes"
},
"empty-state": {
"message": "No results found"
},
"scopes": {
"apply-selected-scopes": "Apply",
"selected-scopes-label": "Scopes: "
},
"search-box": {
"placeholder": "Search or jump to..."
},