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:
co-authored by
Tobias Skarhed
parent
d4fd3aa7c7
commit
8435eb2876
@@ -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} / </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>
|
||||
<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 });
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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..."
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user