diff --git a/public/app/features/commandPalette/CommandPalette.tsx b/public/app/features/commandPalette/CommandPalette.tsx index 28419162be3..c93c2d3b0a9 100644 --- a/public/app/features/commandPalette/CommandPalette.tsx +++ b/public/app/features/commandPalette/CommandPalette.tsx @@ -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 ( + + + + ); +} + +/** + * 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(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 ? ( - - - - -
-
- - -
- {isFetchingSearchResults && } -
-
-
- + return ( + + + +
+
+ + + +
+ {isFetchingSearchResults && }
- - - - - ) : null; + {scopesRow ?
{scopesRow}
: null} +
+ +
+
+
+
+
+ ); +} + +/** + * 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 && ( + + {ancestorActions.map((action, index) => ( + {action.name} /  + ))} + + ) + ); } 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), + }), }; }; diff --git a/public/app/features/commandPalette/KBarResults.tsx b/public/app/features/commandPalette/KBarResults.tsx index af01165f0b4..2eb8a5aecf7 100644 --- a/public/app/features/commandPalette/KBarResults.tsx +++ b/public/app/features/commandPalette/KBarResults.tsx @@ -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(); diff --git a/public/app/features/commandPalette/KBarSearch.tsx b/public/app/features/commandPalette/KBarSearch.tsx new file mode 100644 index 00000000000..373be079184 --- /dev/null +++ b/public/app/features/commandPalette/KBarSearch.tsx @@ -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 & { + 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 ( + { + 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, + }), + }; +}; diff --git a/public/app/features/commandPalette/ScopesRow.tsx b/public/app/features/commandPalette/ScopesRow.tsx new file mode 100644 index 00000000000..51d8127e394 --- /dev/null +++ b/public/app/features/commandPalette/ScopesRow.tsx @@ -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 ( + <> + + + Scopes: + + {treeScopes?.map((scope) => { + return ( + { + toggleNode(scope); + }} + /> + ); + })} + + {isDirty && ( + + )} + + ); +} + +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), + }), + }; +}; diff --git a/public/app/features/commandPalette/actions/dashboardActions.ts b/public/app/features/commandPalette/actions/dashboardActions.ts index 5b9f01c6e71..9c18f706b17 100644 --- a/public/app/features/commandPalette/actions/dashboardActions.ts +++ b/public/app/features/commandPalette/actions/dashboardActions.ts @@ -79,6 +79,11 @@ export async function getSearchResultActions(searchQuery: string): Promise([]); const [isFetchingSearchResults, setIsFetchingSearchResults] = useState(false); diff --git a/public/app/features/commandPalette/actions/recentScopesActions.ts b/public/app/features/commandPalette/actions/recentScopesActions.ts index 2cac2372522..099ccee95bd 100644 --- a/public/app/features/commandPalette/actions/recentScopesActions.ts +++ b/public/app/features/commandPalette/actions/recentScopesActions.ts @@ -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) => { diff --git a/public/app/features/commandPalette/actions/staticActions.ts b/public/app/features/commandPalette/actions/staticActions.ts index 5dd1415a822..d1ff72fd81d 100644 --- a/public/app/features/commandPalette/actions/staticActions.ts +++ b/public/app/features/commandPalette/actions/staticActions.ts @@ -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]); +} diff --git a/public/app/features/commandPalette/actions/useActions.ts b/public/app/features/commandPalette/actions/useActions.ts deleted file mode 100644 index 5de66ff7375..00000000000 --- a/public/app/features/commandPalette/actions/useActions.ts +++ /dev/null @@ -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([]); - const [recentDashboardActions, setRecentDashboardActions] = useState([]); - 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]; -} diff --git a/public/app/features/commandPalette/actions/useActions.tsx b/public/app/features/commandPalette/actions/useActions.tsx new file mode 100644 index 00000000000..3e4225b76c9 --- /dev/null +++ b/public/app/features/commandPalette/actions/useActions.tsx @@ -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([]); + 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 ? ( + + ) : 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('/') ?? ['']; +} diff --git a/public/app/features/commandPalette/values.ts b/public/app/features/commandPalette/values.ts index a3bc8b24ddf..2fc599254b0 100644 --- a/public/app/features/commandPalette/values.ts +++ b/public/app/features/commandPalette/values.ts @@ -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; diff --git a/public/app/features/scopes/selector/ScopesSelectorService.test.ts b/public/app/features/scopes/selector/ScopesSelectorService.test.ts new file mode 100644 index 00000000000..a511ec86831 --- /dev/null +++ b/public/app/features/scopes/selector/ScopesSelectorService.test.ts @@ -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; + let dashboardsService: jest.Mocked; + + 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; + + dashboardsService = { + fetchDashboards: jest.fn(), + } as unknown as jest.Mocked; + + 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([]); + }); + }); +}); diff --git a/public/app/features/scopes/selector/ScopesSelectorService.ts b/public/app/features/scopes/selector/ScopesSelectorService.ts index 8c34ddd68e7..edccb3cc5e1 100644 --- a/public/app/features/scopes/selector/ScopesSelectorService.ts +++ b/public/app/features/scopes/selector/ScopesSelectorService.ts @@ -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 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 { - 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 ({ - scope: getEmptyScopeObject(scopeName), + let selectedScopes = treeScopes.map(({ scopeName, path, title }) => ({ + scope: getEmptyScopeObject(scopeName, title), path, })); @@ -189,11 +227,26 @@ export class ScopesSelectorService extends ScopesServiceBase 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 { 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; } diff --git a/public/app/features/scopes/selector/ScopesTreeItem.tsx b/public/app/features/scopes/selector/ScopesTreeItem.tsx index 9c029ea25b4..f0326f2ffca 100644 --- a/public/app/features/scopes/selector/ScopesTreeItem.tsx +++ b/public/app/features/scopes/selector/ScopesTreeItem.tsx @@ -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 }); }} /> ) diff --git a/public/app/features/scopes/selector/types.ts b/public/app/features/scopes/selector/types.ts index 672a6f158b4..11d6e3c7693 100644 --- a/public/app/features/scopes/selector/types.ts +++ b/public/app/features/scopes/selector/types.ts @@ -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; diff --git a/public/app/features/scopes/tests/selector.test.ts b/public/app/features/scopes/tests/selector.test.ts index bf94cafa922..edfda65b24a 100644 --- a/public/app/features/scopes/tests/selector.test.ts +++ b/public/app/features/scopes/tests/selector.test.ts @@ -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'); diff --git a/public/app/features/scopes/utils.ts b/public/app/features/scopes/utils.ts index d80a19d239c..d86a6f69b02 100644 --- a/public/app/features/scopes/utils.ts +++ b/public/app/features/scopes/utils.ts @@ -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: '', diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 47654e02c49..2e2bd544668 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -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..." },