Scopes: Global scopes search in command palette (#105597)

* 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

* Add basic global search

* Change scope selector data structures

* Fix scope selector functionality

* Fix errors in selector and cmdk actions

* Fix cmdk actions

* Fix global search in cmdk

* Fix some merge edits

* merge diffs

* Small merge fixes

* Fix ScopesSelectorService.test.ts

* Fix tests

* Remove unrelated lint fixes

* Move ScopesTreeItemList.tsx into separate file

* Simplify if condition

* Use node.title in the scopesRow

* Use better dependency array for actions

* Make recentScopes more robust

* Fix beterrer

* Update betterer file

* Add test for changeScopes early return

* Fix input tooltip title access

---------

Co-authored-by: Tobias Skarhed <1438972+tskarhed@users.noreply.github.com>
gamab/apis-proxy/authn
Andrej Ocenas 1 month ago committed by GitHub
parent 6c9fd45837
commit 1aaf8adee4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 6
      .betterer.results
  2. 13
      packages/grafana-data/src/types/scopes.ts
  3. 26
      public/app/features/commandPalette/ScopesRow.tsx
  4. 6
      public/app/features/commandPalette/actions/recentScopesActions.ts
  5. 177
      public/app/features/commandPalette/actions/useActions.tsx
  6. 101
      public/app/features/scopes/ScopesApiClient.ts
  7. 16
      public/app/features/scopes/ScopesService.ts
  8. 14
      public/app/features/scopes/selector/RecentScopes.tsx
  9. 126
      public/app/features/scopes/selector/ScopesInput.tsx
  10. 43
      public/app/features/scopes/selector/ScopesSelector.tsx
  11. 306
      public/app/features/scopes/selector/ScopesSelectorService.test.ts
  12. 550
      public/app/features/scopes/selector/ScopesSelectorService.ts
  13. 171
      public/app/features/scopes/selector/ScopesTree.tsx
  14. 13
      public/app/features/scopes/selector/ScopesTreeHeadline.tsx
  15. 205
      public/app/features/scopes/selector/ScopesTreeItem.tsx
  16. 92
      public/app/features/scopes/selector/ScopesTreeItemList.tsx
  17. 29
      public/app/features/scopes/selector/ScopesTreeLoading.tsx
  18. 22
      public/app/features/scopes/selector/ScopesTreeSearch.tsx
  19. 206
      public/app/features/scopes/selector/scopesTreeUtils.test.ts
  20. 111
      public/app/features/scopes/selector/scopesTreeUtils.ts
  21. 39
      public/app/features/scopes/selector/types.ts
  22. 38
      public/app/features/scopes/tests/tree.test.ts
  23. 9
      public/app/features/scopes/tests/utils/assertions.ts
  24. 42
      public/app/features/scopes/tests/utils/mocks.ts
  25. 53
      public/app/features/scopes/tests/utils/selectors.ts

@ -2779,12 +2779,6 @@ exports[`better eslint`] = {
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
[0, 0, 0, "Unexpected any. Specify a different type.", "2"]
],
"public/app/features/scopes/selector/ScopesSelectorService.ts:5381": [
[0, 0, 0, "Direct usage of localStorage is not allowed. import store from @grafana/data instead", "0"],
[0, 0, 0, "Direct usage of localStorage is not allowed. import store from @grafana/data instead", "1"],
[0, 0, 0, "Direct usage of localStorage is not allowed. import store from @grafana/data instead", "2"],
[0, 0, 0, "Direct usage of localStorage is not allowed. import store from @grafana/data instead", "3"]
],
"public/app/features/search/page/components/columns.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],

@ -58,6 +58,7 @@ export interface ScopeSpec {
// TODO: Use Resource from apiserver when we export the types
export interface Scope {
metadata: {
// Name is actually the ID of the resource, use spec.title for user readable string
name: string;
};
spec: ScopeSpec;
@ -71,14 +72,26 @@ export interface ScopeNodeSpec {
title: string;
description?: string;
// If true for a scope category/type, it means only single child can be selected at a time.
disableMultiSelect?: boolean;
// Id of a scope this node links to. Can be blank for nodes only representing category/type.
linkId?: string;
// Right now only scope can be linked but in the future this may be other types.
linkType?: ScopeNodeLinkType;
// Id of the parent node.
parentName?: string;
}
// TODO: Use Resource from apiserver when we export the types
// Scope node represents a node in a tree that is shown to users. Each node can be a category/type with more children
// nodes and/or (meaning some can be both) a node representing a selectable scope. Each node can link to a scope but
// multiple nodes can link to the same scope, meaning a scope is part of multiple categories/types.
export interface ScopeNode {
metadata: {
// Name is actually the ID of the resource, use spec.title for user readable string
name: string;
};
spec: ScopeNodeSpec;

@ -5,20 +5,22 @@ import { Trans } from '@grafana/i18n';
import { Button, FilterPill, Stack, Text, useStyles2 } from '@grafana/ui';
import { getModKey } from '../../core/utils/browser';
import { ToggleNode, TreeScope } from '../scopes/selector/types';
import { NodesMap, ScopesMap, SelectedScope } from '../scopes/selector/types';
type Props = {
treeScopes: TreeScope[];
selectedScopes: SelectedScope[];
isDirty: boolean;
apply: () => void;
toggleNode: (node: ToggleNode) => void;
deselectScope: (id: string) => void;
scopes: ScopesMap;
nodes: NodesMap;
};
/**
* 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) {
export function ScopesRow({ selectedScopes, isDirty, apply, deselectScope, scopes, nodes }: Props) {
const styles = useStyles2(getStyles);
return (
<>
@ -27,15 +29,23 @@ export function ScopesRow({ treeScopes, isDirty, apply, toggleNode }: Props) {
<Trans i18nKey={'command-palette.scopes.selected-scopes-label'}>Scopes: </Trans>
</span>
<Stack wrap={'wrap'}>
{treeScopes?.map((scope) => {
{selectedScopes?.map((scope) => {
// We need to load scope data when an item is selected, so there may be a delay until we have it. We fallback
// to node.title if we have it and if not, show just a scopeId. node.title and scope.title should probably be
// the same, but it's not guaranteed
const label =
scopes[scope.scopeId]?.spec.title ||
(scope.scopeNodeId && nodes[scope.scopeNodeId]?.spec.title) ||
scope.scopeId;
return (
<FilterPill
key={scope.scopeName}
key={scope.scopeId}
selected={true}
icon={'times'}
label={scope.title}
label={label}
onClick={() => {
toggleNode(scope);
deselectScope(scope.scopeNodeId || scope.scopeId);
}}
/>
);

@ -17,12 +17,12 @@ export function getRecentScopesActions(): CommandPaletteAction[] {
return recentScopes.map((recentScope) => {
return {
id: recentScope.map((scope) => scope.scope.spec.title).join(', '),
name: recentScope.map((scope) => scope.scope.spec.title).join(', '),
id: recentScope.map((scope) => scope.spec.title).join(', '),
name: recentScope.map((scope) => scope.spec.title).join(', '),
section: t('command-palette.section.recent-scopes', 'Recent scopes'),
priority: RECENT_SCOPES_PRIORITY,
perform: () => {
scopesSelectorService.changeScopes(recentScope.map((scope) => scope.scope.metadata.name));
scopesSelectorService.changeScopes(recentScope.map((scope) => scope.metadata.name));
},
};
});

@ -1,14 +1,16 @@
import { useRegisterActions } from 'kbar';
import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
import { fromPairs, last } from 'lodash';
import { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useObservable } from 'react-use';
import { Observable } from 'rxjs';
import { ScopeNode } from '@grafana/data';
import { t } from '@grafana/i18n/internal';
import { config } from '@grafana/runtime';
import { useScopesServices } from '../../scopes/ScopesContextProvider';
import { ScopesSelectorServiceState } from '../../scopes/selector/ScopesSelectorService';
import { NodesMap, Node, TreeScope, ToggleNode } from '../../scopes/selector/types';
import { NodesMap, SelectedScope, TreeNode } from '../../scopes/selector/types';
import { ScopesRow } from '../ScopesRow';
import { CommandPaletteAction } from '../types';
import { SCOPES_PRIORITY } from '../values';
@ -72,19 +74,25 @@ export function useRegisterScopesActions(
return { scopesRow: undefined };
}
const { updateNode, toggleNodeSelect, apply, resetSelection } = services.scopesSelectorService;
const { updateNode, selectScope, deselectScope, apply, resetSelection, searchAllNodes } =
services.scopesSelectorService;
// Initialize the scopes first time this runs and reset the scopes that were selected on unmount.
useEffect(() => {
updateNode([''], true, '');
updateNode('', true, '');
resetSelection();
return () => {
resetSelection();
};
}, [updateNode, resetSelection]);
// Load next level of scopes when the parentId changes.
const globalNodes = useGlobalScopesSearch(searchQuery, searchAllNodes, parentId);
// Load the next level of scopes when the parentId changes.
useEffect(() => {
updateNode(getScopePathFromActionId(parentId), true, searchQuery);
if (parentId) {
updateNode(parentId === 'scopes' ? '' : last(parentId.split('/'))!, true, searchQuery);
}
}, [updateNode, searchQuery, parentId]);
const selectorServiceState: ScopesSelectorServiceState | undefined = useObservable(
@ -92,21 +100,25 @@ export function useRegisterScopesActions(
services.scopesSelectorService.state
);
const { nodes, loading, loadingNodeName, treeScopes, selectedScopes } = selectorServiceState;
const nodesActions = mapScopeNodesToActions(nodes, treeScopes, toggleNodeSelect);
const { nodes, scopes, tree, selectedScopes, appliedScopes } = selectorServiceState;
// 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 nodesActions = useMemo(() => {
// If we have nodes from global search, we show those in a flat list.
return globalNodes
? Object.values(globalNodes).map((node) => mapScopeNodeToAction(node, selectScope))
: mapScopesNodesTreeToActions(nodes, tree!, selectedScopes, selectScope);
}, [globalNodes, nodes, tree, selectedScopes, selectScope]);
useRegisterActions(nodesActions, [nodesActions]);
// Check if we have different selection than what is already applied. Used to show the apply button.
const isDirty =
treeScopes
.map((t) => t.scopeName)
appliedScopes
.map((t) => t.scopeId)
.sort()
.join('') !==
selectedScopes
.map((s) => s.scope.metadata.name)
.map((s) => s.scopeId)
.sort()
.join('');
@ -129,17 +141,53 @@ export function useRegisterScopesActions(
return {
scopesRow:
isDirty || treeScopes?.length ? (
<ScopesRow toggleNode={toggleNodeSelect} treeScopes={treeScopes} apply={finalApply} isDirty={isDirty} />
isDirty || selectedScopes?.length ? (
<ScopesRow
nodes={nodes}
scopes={scopes}
deselectScope={deselectScope}
selectedScopes={selectedScopes}
apply={finalApply}
isDirty={isDirty}
/>
) : null,
};
}
function mapScopeNodesToActions(
nodes: NodesMap,
selectedScopes: TreeScope[],
toggleNodeSelect: (node: ToggleNode) => void
function useGlobalScopesSearch(
searchQuery: string,
searchAllNodes: (search: string, limit: number) => Promise<ScopeNode[]>,
parentId?: string | null
) {
const [nodes, setNodes] = useState<NodesMap | undefined>(undefined);
const searchQueryRef = useRef<string>();
// Load next level of scopes when the parentId changes.
useEffect(() => {
if (!parentId && searchQuery && config.featureToggles.scopeSearchAllLevels) {
// We only search globally if there is no parentId
searchQueryRef.current = searchQuery;
searchAllNodes(searchQuery, 10).then((nodes) => {
if (searchQueryRef.current === searchQuery) {
const nodesMap = fromPairs(nodes.map((n) => [n.metadata.name, n]));
setNodes(nodesMap);
}
});
} else {
searchQueryRef.current = undefined;
setNodes(undefined);
}
}, [searchAllNodes, searchQuery, parentId]);
return nodes;
}
function mapScopesNodesTreeToActions(
nodes: NodesMap,
tree: TreeNode,
selectedScopes: SelectedScope[],
selectScope: (id: string) => void
): CommandPaletteAction[] {
const actions: CommandPaletteAction[] = [
{
id: 'scopes',
@ -150,45 +198,78 @@ function mapScopeNodesToActions(
},
];
const traverse = (node: Node, parentId: string) => {
const traverse = (tree: TreeNode, parentId: string | undefined) => {
// TODO: not sure how and why a node.nodes can be undefined
if (!node.nodes || Object.keys(node.nodes).length === 0) {
if (!tree.children || Object.keys(tree.children).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;
}
}
for (const key of Object.keys(tree.children)) {
const childTreeNode = tree.children[key];
const child = nodes[key];
const action: CommandPaletteAction = {
id: `${parentId}/${child.name}`,
name: child.title,
keywords: `${child.title} ${child.name}`,
priority: SCOPES_PRIORITY,
parent: parentId,
};
const scopeIsSelected = selectedScopes.some((s) => {
if (s.scopeNodeId) {
return s.scopeNodeId === child.metadata.name;
} else {
return s.scopeId === child.spec.linkId;
}
});
if (child.nodeType === 'leaf') {
action.perform = () => {
toggleNodeSelect({ scopeName: child.name, path: getScopePathFromActionId(action.id) });
};
// Selected scopes are not shown in the list but in a separate section of the command palette.
if (child.spec.nodeType === 'leaf' && scopeIsSelected) {
continue;
}
let action = mapScopeNodeToAction(child, selectScope, parentId);
actions.push(action);
traverse(child, action.id);
traverse(childTreeNode, action.id);
}
};
traverse(nodes[''], 'scopes');
traverse(tree, '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('/') ?? [''];
/**
* Map scopeNode to cmdK action. The typing is a bit strict and we have 2 different cases where ew create actions
* from global search sort of flatten out or a part of the tree structure.
* @param scopeNode
* @param selectScope
* @param parentId
*/
function mapScopeNodeToAction(
scopeNode: ScopeNode,
selectScope: (id: string) => void,
parentId?: string
): CommandPaletteAction {
let action: CommandPaletteAction;
if (parentId) {
action = {
id: `${parentId}/${scopeNode.metadata.name}`,
name: scopeNode.spec.title,
keywords: `${scopeNode.spec.title} ${scopeNode.metadata.name}`,
priority: SCOPES_PRIORITY,
parent: parentId,
};
// TODO: some non leaf nodes can also be selectable, but we don't have a way to show that in the UI yet.
if (scopeNode.spec.nodeType === 'leaf') {
action.perform = () => {
selectScope(scopeNode.metadata.name);
};
}
} else {
action = {
id: `scopes/${scopeNode.metadata.name}`,
name: scopeNode.spec.title,
keywords: `${scopeNode.spec.title} ${scopeNode.metadata.name}`,
priority: SCOPES_PRIORITY,
section: t('command-palette.action.scopes', 'Scopes'),
subtitle: scopeNode.spec.parentName,
perform: () => {
selectScope(scopeNode.metadata.name);
},
};
}
return action;
}

@ -4,8 +4,6 @@ import { getBackendSrv } from '@grafana/runtime';
import { getAPINamespace } from '../../api/utils';
import { ScopeNavigation } from './dashboards/types';
import { NodeReason, NodesMap, SelectedScope, TreeScope } from './selector/types';
import { getEmptyScopeObject } from './utils';
const apiGroup = 'scope.grafana.app';
const apiVersion = 'v0alpha1';
@ -13,81 +11,50 @@ const apiNamespace = getAPINamespace();
const apiUrl = `/apis/${apiGroup}/${apiVersion}/namespaces/${apiNamespace}`;
export class ScopesApiClient {
private scopesCache = new Map<string, Promise<Scope>>();
async fetchScope(name: string): Promise<Scope> {
if (this.scopesCache.has(name)) {
return this.scopesCache.get(name)!;
async fetchScope(name: string): Promise<Scope | undefined> {
try {
return await getBackendSrv().get<Scope>(apiUrl + `/scopes/${name}`);
} catch (err) {
// TODO: maybe some better error handling
console.error(err);
return undefined;
}
const response = new Promise<Scope>(async (resolve) => {
const basicScope = getEmptyScopeObject(name);
try {
const serverScope = await getBackendSrv().get<Scope>(apiUrl + `/scopes/${name}`);
const scope = {
...basicScope,
...serverScope,
metadata: {
...basicScope.metadata,
...serverScope.metadata,
},
spec: {
...basicScope.spec,
...serverScope.spec,
},
};
resolve(scope);
} catch (err) {
this.scopesCache.delete(name);
resolve(basicScope);
}
});
this.scopesCache.set(name, response);
return response;
}
async fetchMultipleScopes(treeScopes: TreeScope[]): Promise<SelectedScope[]> {
const scopes = await Promise.all(treeScopes.map(({ scopeName }) => this.fetchScope(scopeName)));
return scopes.map<SelectedScope>((scope, idx) => {
return {
scope,
path: treeScopes[idx].path,
};
});
async fetchMultipleScopes(scopesIds: string[]): Promise<Scope[]> {
const scopes = await Promise.all(scopesIds.map((id) => this.fetchScope(id)));
return scopes.filter((scope) => scope !== undefined);
}
/**
* @param parent
* @param query Filters by title substring
* Fetches a map of nodes based on the specified options.
*
* @param {Object} options An object to configure the node fetch operation.
* @param {string|undefined} options.parent The parent node identifier to fetch children for, or undefined if no parent scope is required.
* @param {string|undefined} options.query A query string to filter the nodes, or undefined for no filtering.
* @param {number|undefined} options.limit The maximum number of nodes to fetch, defaults to 1000 if undefined. Must be between 1 and 10000.
* @return {Promise<ScopeNode[]>} A promise that resolves to a map of fetched nodes. Returns an empty object if an error occurs.
*/
async fetchNode(parent: string, query: string): Promise<NodesMap> {
async fetchNodes(options: { parent?: string; query?: string; limit?: number }): Promise<ScopeNode[]> {
const limit = options.limit ?? 1000;
if (!(0 < limit && limit <= 10000)) {
throw new Error('Limit must be between 1 and 10000');
}
try {
const nodes =
(await getBackendSrv().get<{ items: ScopeNode[] }>(apiUrl + `/find/scope_node_children`, { parent, query }))
?.items ?? [];
return nodes.reduce<NodesMap>((acc, { metadata: { name }, spec }) => {
acc[name] = {
name,
...spec,
expandable: spec.nodeType === 'container',
selectable: spec.linkType === 'scope',
expanded: false,
query: '',
reason: NodeReason.Result,
nodes: {},
};
return acc;
}, {});
(
await getBackendSrv().get<{ items: ScopeNode[] }>(apiUrl + `/find/scope_node_children`, {
parent: options.parent,
query: options.query,
limit,
})
)?.items ?? [];
return nodes;
} catch (err) {
return {};
return [];
}
}

@ -38,7 +38,10 @@ export class ScopesService implements ScopesContextValue {
this._stateObservable = new BehaviorSubject({
...this._state.getValue(),
value: this.selectorService.state.selectedScopes.map(({ scope }) => scope),
value: this.selectorService.state.appliedScopes
.map((s) => this.selectorService.state.scopes[s.scopeId])
// Filter out scopes if we don't have actual scope data loaded yet
.filter((s) => s),
loading: this.selectorService.state.loading,
drawerOpened: this.dashboardsService.state.drawerOpened,
});
@ -91,8 +94,8 @@ export class ScopesService implements ScopesContextValue {
// Update the URL based on change in the scopes state
this.subscriptions.push(
selectorService.subscribeToState((state, prev) => {
const oldScopeNames = prev.selectedScopes.map((scope) => scope.scope.metadata.name);
const newScopeNames = state.selectedScopes.map((scope) => scope.scope.metadata.name);
const oldScopeNames = prev.appliedScopes.map((scope) => scope.scopeId);
const newScopeNames = state.appliedScopes.map((scope) => scope.scopeId);
if (!isEqual(oldScopeNames, newScopeNames)) {
this.locationService.partial({ scopes: newScopeNames }, true);
}
@ -139,7 +142,7 @@ export class ScopesService implements ScopesContextValue {
if (enabled) {
this.locationService.partial(
{
scopes: this.selectorService.state.selectedScopes.map(({ scope }) => scope.metadata.name),
scopes: this.selectorService.state.appliedScopes.map((s) => s.scopeId),
},
true
);
@ -156,7 +159,10 @@ export class ScopesService implements ScopesContextValue {
map((state) => ({
// We only need these 2 properties from the selectorService state.
// We do mapping here but mainly to make the distinctUntilChanged simpler
selectedScopes: state.selectedScopes.map(({ scope }) => scope),
selectedScopes: state.appliedScopes
.map((s) => state.scopes[s.scopeId])
// Filter out scopes if we don't have actual scope data loaded yet
.filter((s) => s),
loading: state.loading,
})),
distinctUntilChanged(

@ -1,15 +1,13 @@
import { css } from '@emotion/css';
import { useId, useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { GrafanaTheme2, Scope } from '@grafana/data';
import { Trans } from '@grafana/i18n';
import { useStyles2, Stack, Text, Icon, Box } from '@grafana/ui';
import { SelectedScope } from './types';
interface RecentScopesProps {
recentScopes: SelectedScope[][];
onSelect: (scopes: SelectedScope[]) => void;
recentScopes: Scope[][];
onSelect: (scopeIds: string[]) => void;
}
export const RecentScopes = ({ recentScopes, onSelect }: RecentScopesProps) => {
@ -39,12 +37,12 @@ export const RecentScopes = ({ recentScopes, onSelect }: RecentScopesProps) => {
recentScopes.map((recentScopeSet) => (
<button
className={styles.recentScopeButton}
key={recentScopeSet.map((s) => s.scope.metadata.name).join(',')}
key={recentScopeSet.map((s) => s.metadata.name).join(',')}
onClick={() => {
onSelect(recentScopeSet);
onSelect(recentScopeSet.map((s) => s.metadata.name));
}}
>
<Text>{recentScopeSet.map((s) => s.scope.spec.title).join(', ')}</Text>
<Text>{recentScopeSet.map((s) => s.spec.title).join(', ')}</Text>
</button>
))}
</Stack>

@ -5,70 +5,51 @@ import { GrafanaTheme2 } from '@grafana/data';
import { useTranslate } from '@grafana/i18n';
import { IconButton, Input, Tooltip, useStyles2 } from '@grafana/ui';
import { NodesMap, SelectedScope } from './types';
import { getPathOfNode } from './scopesTreeUtils';
import { NodesMap, ScopesMap, SelectedScope } from './types';
export interface ScopesInputProps {
nodes: NodesMap;
scopes: SelectedScope[];
scopes: ScopesMap;
appliedScopes: SelectedScope[];
disabled: boolean;
loading: boolean;
onInputClick: () => void;
onRemoveAllClick: () => void;
}
export function ScopesInput({ nodes, scopes, disabled, loading, onInputClick, onRemoveAllClick }: ScopesInputProps) {
const styles = useStyles2(getStyles);
/**
* Shows the applied scopes in an input like element which opens the scope selector when clicked.
*/
export function ScopesInput({
nodes,
scopes,
appliedScopes,
disabled,
loading,
onInputClick,
onRemoveAllClick,
}: ScopesInputProps) {
const [tooltipVisible, setTooltipVisible] = useState(false);
useEffect(() => {
setTooltipVisible(false);
}, [scopes]);
const scopesPaths = useMemo(() => {
const pathsScopesMap = scopes.reduce<Record<string, string>>((acc, { scope, path }) => {
let currentLevel = nodes;
const titles = path.reduce<string[]>((acc, nodeName) => {
const cl = currentLevel?.[nodeName];
if (!cl) {
return acc;
}
const { title, nodes } = cl;
currentLevel = nodes;
acc.push(title);
return acc;
}, []);
if (titles[0] === '') {
titles.splice(0, 1);
}
const scopeName = titles.length > 0 ? titles.pop()! : scope.spec.title;
const titlesString = titles.length > 0 ? titles.join(' > ') : '';
acc[titlesString] = acc[titlesString] ? `${acc[titlesString]}, ${scopeName}` : scopeName;
return acc;
}, {});
return (
<>
{Object.entries(pathsScopesMap).map(([path, scopesTitles]) => (
<p key={path} className={styles.scopePath}>
{path ? `${path} > ${scopesTitles}` : scopesTitles}
</p>
))}
</>
);
}, [nodes, scopes, styles]);
const scopesTitles = useMemo(() => scopes.map(({ scope }) => scope.spec.title).join(', '), [scopes]);
}, [appliedScopes]);
const tooltipContent =
appliedScopes.length > 0 ? <ScopesTooltip nodes={nodes} scopes={scopes} appliedScopes={appliedScopes} /> : <></>;
const scopesTitles = useMemo(
() =>
appliedScopes
.map(
(s) =>
// If we are still loading the scope data just show the id
scopes[s.scopeId]?.spec.title || s.scopeId
)
.join(', '),
[appliedScopes, scopes]
);
const { t } = useTranslate();
@ -83,7 +64,7 @@ export function ScopesInput({ nodes, scopes, disabled, loading, onInputClick, on
aria-label={t('scopes.selector.input.placeholder', 'Select scopes...')}
data-testid="scopes-selector-input"
suffix={
scopes.length > 0 && !disabled ? (
appliedScopes.length > 0 && !disabled ? (
<IconButton
aria-label={t('scopes.selector.input.removeAll', 'Remove all scopes')}
name="times"
@ -101,16 +82,53 @@ export function ScopesInput({ nodes, scopes, disabled, loading, onInputClick, on
}}
/>
),
[disabled, loading, onInputClick, onRemoveAllClick, scopes, scopesTitles, t]
[disabled, loading, onInputClick, onRemoveAllClick, appliedScopes, scopesTitles, t]
);
return (
<Tooltip content={scopesPaths} show={scopes.length === 0 ? false : tooltipVisible}>
<Tooltip content={tooltipContent} show={appliedScopes.length === 0 ? false : tooltipVisible}>
{input}
</Tooltip>
);
}
export interface ScopesTooltipProps {
nodes: NodesMap;
scopes: ScopesMap;
appliedScopes: SelectedScope[];
}
function ScopesTooltip({ nodes, scopes, appliedScopes }: ScopesTooltipProps) {
const styles = useStyles2(getStyles);
let nicePath: string[] | undefined;
if (appliedScopes[0].scopeNodeId) {
let path = getPathOfNode(appliedScopes[0].scopeNodeId, nodes);
// Get reed of empty root section and the actual scope node
path = path.slice(1, -1);
// We may not have all the nodes in path loaded
nicePath = path.map((p) => nodes[p]?.spec.title).filter((p) => p);
}
const scopeNames = appliedScopes.map((s) => {
if (s.scopeNodeId) {
return nodes[s.scopeNodeId]?.spec.title || s.scopeNodeId;
} else {
return scopes[s.scopeId]?.spec.title || s.scopeId;
}
});
return (
<>
<p className={styles.scopePath}>
{(nicePath && nicePath.length > 0 ? nicePath.join(' > ') + ' > ' : '') + scopeNames.join(', ')}
</p>
</>
);
}
const getStyles = (theme: GrafanaTheme2) => {
return {
scopePath: css({

@ -33,11 +33,28 @@ export const ScopesSelector = () => {
if (!services || !scopes || !scopes.state.enabled || !selectorServiceState) {
return null;
}
const { nodes, loadingNodeName, selectedScopes, opened, treeScopes } = selectorServiceState;
const {
nodes,
loadingNodeName,
opened,
selectedScopes,
appliedScopes,
tree,
scopes: scopesMap,
} = selectorServiceState;
const { scopesService, scopesSelectorService, scopesDashboardsService } = services;
const { readOnly, drawerOpened, loading } = scopes.state;
const { open, removeAllScopes, closeAndApply, closeAndReset, updateNode, toggleNodeSelect, getRecentScopes } =
scopesSelectorService;
const {
open,
removeAllScopes,
closeAndApply,
closeAndReset,
updateNode,
selectScope,
deselectScope,
getRecentScopes,
} = scopesSelectorService;
const recentScopes = getRecentScopes();
@ -61,7 +78,8 @@ export const ScopesSelector = () => {
<ScopesInput
nodes={nodes}
scopes={selectedScopes}
scopes={scopesMap}
appliedScopes={appliedScopes}
disabled={readOnly}
loading={loading}
onInputClick={() => {
@ -76,21 +94,22 @@ export const ScopesSelector = () => {
<Drawer title={t('scopes.selector.title', 'Select scopes')} size="sm" onClose={closeAndReset}>
<div className={styles.drawerContainer}>
<div className={styles.treeContainer}>
{loading ? (
{loading || !tree ? (
<Spinner data-testid="scopes-selector-loading" />
) : (
<>
<ScopesTree
nodes={nodes}
nodePath={['']}
tree={tree}
loadingNodeName={loadingNodeName}
scopes={treeScopes}
onNodeUpdate={updateNode}
onNodeSelectToggle={toggleNodeSelect}
recentScopes={recentScopes}
onRecentScopesSelect={(recentScopeSet) => {
scopesSelectorService.changeScopes(recentScopeSet.map((s) => s.scope.metadata.name));
scopesSelectorService.closeAndApply();
selectedScopes={selectedScopes}
scopeNodes={nodes}
selectScope={selectScope}
deselectScope={deselectScope}
onRecentScopesSelect={(scopeIds: string[]) => {
scopesSelectorService.changeScopes(scopeIds);
scopesSelectorService.closeAndReset();
}}
/>
</>

@ -1,10 +1,9 @@
import { Scope } from '@grafana/data';
import { Scope, ScopeNode, Store } from '@grafana/data';
import { ScopesApiClient } from '../ScopesApiClient';
import { ScopesDashboardsService } from '../dashboards/ScopesDashboardsService';
import { ScopesSelectorService } from './ScopesSelectorService';
import { Node, NodeReason, NodesMap } from './types';
import { RECENT_SCOPES_KEY, ScopesSelectorService } from './ScopesSelectorService';
describe('ScopesSelectorService', () => {
let service: ScopesSelectorService;
@ -24,27 +23,37 @@ describe('ScopesSelectorService', () => {
},
};
const mockNode: Node = {
name: 'test-scope',
title: 'Test Node',
reason: NodeReason.Result,
nodeType: 'container',
expandable: true,
selectable: false,
expanded: false,
query: '',
nodes: {},
const mockScope2: Scope = {
metadata: {
name: 'recent-scope',
},
spec: {
title: 'test-scope',
type: 'scope',
category: 'scope',
description: 'test scope',
filters: [],
},
};
const mockNodesMap: NodesMap = {
'': mockNode,
const mockNode: ScopeNode = {
metadata: { name: 'test-scope-node' },
spec: { linkId: 'test-scope', linkType: 'scope', parentName: '', nodeType: 'leaf', title: 'test-scope-node' },
};
let storeValue: Record<string, unknown> = {};
beforeEach(() => {
apiClient = {
fetchScope: jest.fn().mockResolvedValue(mockScope),
fetchMultipleScopes: jest.fn().mockResolvedValue([{ scope: mockScope, path: ['', 'test-scope'] }]),
fetchNode: jest.fn().mockResolvedValue(mockNodesMap),
fetchMultipleScopes: jest.fn().mockResolvedValue([mockScope]),
fetchNodes: jest.fn().mockImplementation((options: { parent?: string; query?: string; limit?: number }) => {
if (options.parent === '' && !options.query) {
return [mockNode];
} else {
return [];
}
}),
fetchDashboards: jest.fn().mockResolvedValue([]),
fetchScopeNavigations: jest.fn().mockResolvedValue([]),
} as unknown as jest.Mocked<ScopesApiClient>;
@ -53,214 +62,195 @@ describe('ScopesSelectorService', () => {
fetchDashboards: jest.fn(),
} as unknown as jest.Mocked<ScopesDashboardsService>;
service = new ScopesSelectorService(apiClient, dashboardsService);
storeValue = {};
const store = {
get(key: string) {
return storeValue[key];
},
set(key: string, value: string) {
storeValue[key] = value;
},
};
service = new ScopesSelectorService(apiClient, dashboardsService, store as Store);
});
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);
await service.updateNode('', true, '');
expect(service.state.nodes['test-scope-node']).toEqual(mockNode);
expect(service.state.tree).toMatchObject({
children: { 'test-scope-node': { expanded: false, scopeNodeId: 'test-scope-node' } },
expanded: true,
query: '',
scopeNodeId: '',
});
expect(apiClient.fetchNodes).toHaveBeenCalledWith({ parent: '', query: '' });
});
it('should update node query and fetch children when query changes', async () => {
await service.updateNode([''], false, 'new-query');
expect(apiClient.fetchNode).toHaveBeenCalledWith('', 'new-query');
await service.updateNode('', true, 'new-query');
expect(service.state.tree).toMatchObject({
children: {},
expanded: true,
query: 'new-query',
scopeNodeId: '',
});
expect(apiClient.fetchNodes).toHaveBeenCalledWith({ parent: '', query: 'new-query' });
});
it('should not fetch children when node is collapsed and query is unchanged', async () => {
// First expand the node
await service.updateNode([''], true, '');
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);
await service.updateNode('', false, '');
// Only the first expansion should trigger fetchNodes
expect(apiClient.fetchNodes).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');
describe('selectScope and deselectScope', () => {
beforeEach(async () => {
await service.updateNode('', true, '');
});
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 select a scope', async () => {
await service.selectScope('test-scope-node');
expect(service.state.selectedScopes).toEqual([{ scopeId: 'test-scope', scopeNodeId: 'test-scope-node' }]);
});
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([]);
it('should deselect a selected scope', async () => {
await service.selectScope('test-scope-node');
await service.deselectScope('test-scope-node');
expect(service.state.selectedScopes).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',
},
]);
it('should apply the provided scope names', async () => {
await service.changeScopes(['test-scope']);
expect(service.state.appliedScopes).toEqual([{ scopeId: 'test-scope' }]);
});
});
describe('open', () => {
it('should open the selector and load root nodes if not loaded', async () => {
await service.open();
it('should skip update if setting same scopes as are already applied', async () => {
const subscribeFn = jest.fn();
const sub = service.subscribeToState(subscribeFn);
expect(service.state.opened).toBe(true);
});
await service.changeScopes(['test-scope', 'recent-scope']);
expect(service.state.appliedScopes).toEqual([{ scopeId: 'test-scope' }, { scopeId: 'recent-scope' }]);
expect(subscribeFn).toHaveBeenCalledTimes(2);
it('should not reload root nodes if already loaded', async () => {
// First load the nodes
await service.updateNode([''], true, '');
await service.changeScopes(['test-scope', 'recent-scope']);
expect(service.state.appliedScopes).toEqual([{ scopeId: 'test-scope' }, { scopeId: 'recent-scope' }]);
// Should not be called again
expect(subscribeFn).toHaveBeenCalledTimes(2);
// Reset the mock to check if it's called again
apiClient.fetchNode.mockClear();
// Order should not matter
await service.changeScopes(['recent-scope', 'test-scope']);
expect(service.state.appliedScopes).toEqual([{ scopeId: 'test-scope' }, { scopeId: 'recent-scope' }]);
// Should not be called again
expect(subscribeFn).toHaveBeenCalledTimes(2);
// Open the selector
await service.open();
sub.unsubscribe();
});
});
describe('open', () => {
it('should open the selector and load root nodes if not loaded', async () => {
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();
it('should close the selector and reset selectedScopes to match appliedScopes', async () => {
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',
},
]);
expect(service.state.selectedScopes).toEqual(service.state.appliedScopes);
});
});
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.updateNode('', true, '');
await service.selectScope('test-scope-node');
await service.closeAndApply();
expect(service.state.opened).toBe(false);
expect(dashboardsService.fetchDashboards).toHaveBeenCalledWith(['test-scope']);
expect(service.state.appliedScopes).toEqual(service.state.selectedScopes);
});
});
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.selectScope('test-scope-node');
await service.apply();
expect(service.state.opened).toBe(true);
expect(dashboardsService.fetchDashboards).toHaveBeenCalledWith(['test-scope']);
expect(service.state.appliedScopes).toEqual(service.state.selectedScopes);
});
});
describe('resetSelection', () => {
it('should reset treeScopes to match selectedScopes', async () => {
await service.open();
it('should reset selectedScopes to match appliedScopes', async () => {
await service.changeScopes(['test-scope']);
service.resetSelection();
expect(service.state.treeScopes).toEqual([
{
scopeName: 'test-scope',
path: ['', 'test-scope'],
title: 'test-scope',
},
]);
expect(service.state.selectedScopes).toEqual(service.state.appliedScopes);
});
});
describe('removeAllScopes', () => {
it('should remove all selected scopes', async () => {
await service.open();
it('should remove all selected and applied scopes', async () => {
await service.updateNode('', true, '');
await service.selectScope('test-scope-node');
await service.apply();
await service.removeAllScopes();
expect(service.state.appliedScopes).toEqual([]);
});
});
const rootNode = service.state.nodes[''];
rootNode.nodes['test-scope'] = {
...mockNode,
selectable: true,
linkId: 'test-scope',
};
describe('getRecentScopes', () => {
it('should parse and filter scopes', async () => {
await service.updateNode('', true, '');
await service.selectScope('test-scope-node');
await service.apply();
storeValue[RECENT_SCOPES_KEY] = JSON.stringify([[mockScope2], [mockScope]]);
const recentScopes = service.getRecentScopes();
expect(recentScopes).toEqual([[mockScope2]]);
});
service.toggleNodeSelect({ path: ['', 'test-scope'] });
it('should work with old version', async () => {
await service.updateNode('', true, '');
await service.selectScope('test-scope-node');
await service.apply();
await service.removeAllScopes();
storeValue[RECENT_SCOPES_KEY] = JSON.stringify([
[{ scope: mockScope2, path: [] }],
[{ scope: mockScope, path: [] }],
]);
expect(service.state.selectedScopes).toEqual([]);
expect(service.state.treeScopes).toEqual([]);
const recentScopes = service.getRecentScopes();
expect(recentScopes).toEqual([[mockScope2]]);
});
it('should return empty on wrong data', async () => {
storeValue[RECENT_SCOPES_KEY] = JSON.stringify([{ scope: mockScope2 }]);
let recentScopes = service.getRecentScopes();
expect(recentScopes).toEqual([]);
storeValue[RECENT_SCOPES_KEY] = JSON.stringify([]);
recentScopes = service.getRecentScopes();
expect(recentScopes).toEqual([]);
storeValue[RECENT_SCOPES_KEY] = JSON.stringify(null);
recentScopes = service.getRecentScopes();
expect(recentScopes).toEqual([]);
storeValue[RECENT_SCOPES_KEY] = JSON.stringify([[{ metadata: { noName: 'test' } }]]);
recentScopes = service.getRecentScopes();
expect(recentScopes).toEqual([]);
});
});
});

@ -1,260 +1,257 @@
import { isEqual, last } from 'lodash';
import { Scope, store as storeImpl } from '@grafana/data';
import { ScopesApiClient } from '../ScopesApiClient';
import { ScopesServiceBase } from '../ScopesServiceBase';
import { ScopesDashboardsService } from '../dashboards/ScopesDashboardsService';
import { getEmptyScopeObject } from '../utils';
import { Node, NodeReason, NodesMap, SelectedScope, ToggleNode, TreeScope } from './types';
import {
closeNodes,
expandNodes,
getPathOfNode,
isNodeExpandable,
isNodeSelectable,
modifyTreeNodeAtPath,
treeNodeAtPath,
} from './scopesTreeUtils';
import { NodesMap, ScopesMap, SelectedScope, TreeNode } from './types';
const RECENT_SCOPES_KEY = 'grafana.scopes.recent';
export const RECENT_SCOPES_KEY = 'grafana.scopes.recent';
export interface ScopesSelectorServiceState {
// Used to indicate loading of the scopes themselves for example when applying them.
loading: boolean;
// Indicates loading children of a specific scope node.
loadingNodeName: string | undefined;
// Whether the scopes selector drawer is opened
opened: boolean;
loadingNodeName: string | undefined;
// A cache for a specific scope objects that come from the API. nodes being objects in the categories tree and scopes
// the actual scope definitions. They are not guaranteed to be there! For example we may have a scope applied from
// url for which we don't have a node, or scope is still loading after it is selected in the UI. This means any
// access needs to be guarded and not automatically assumed it will return an object.
nodes: NodesMap;
scopes: ScopesMap;
// Scopes that are selected and applied.
appliedScopes: SelectedScope[];
// Scopes that are selected but not applied yet.
selectedScopes: SelectedScope[];
// Representation of what is selected in the tree in the UI. This state may not be yet applied to the selectedScopes.
treeScopes: TreeScope[];
// Simple tree structure for the scopes categories. Each node in a tree has a scopeNodeId which keys the nodes cache
// map.
tree: TreeNode | undefined;
}
export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServiceState> {
constructor(
private apiClient: ScopesApiClient,
private dashboardsService: ScopesDashboardsService
private dashboardsService: ScopesDashboardsService,
private store = storeImpl
) {
super({
loading: false,
opened: false,
loadingNodeName: undefined,
nodes: {
'': {
name: '',
reason: NodeReason.Result,
nodeType: 'container',
title: '',
expandable: true,
selectable: false,
expanded: true,
query: '',
nodes: {},
},
},
nodes: {},
scopes: {},
selectedScopes: [],
treeScopes: [],
appliedScopes: [],
tree: {
expanded: false,
scopeNodeId: '', // root
query: '',
children: undefined,
},
});
}
/**
* Updates a node at a path with the new expanded state and query. If we expand a node or change the query we will
* load its children. The expectation is that this is used to expand or filter nodes that are already loaded, not
* load a deep nested node which parents weren't loaded yet.
* @param path Path to the nodes. Each element in the path is a node name. Has to have at least one element.
* @param expanded
* @param query Substring of the title to filter by.
*/
public updateNode = async (path: string[], expanded: boolean, query: string) => {
if (path.length < 1) {
return;
}
// 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 parentNode: Node | undefined = nodes[''];
let loadingNodeName = path[0];
let currentNode = nodes[''];
if (path.length > 1) {
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;
private expandOrFilterNode = async (scopeNodeId: string, query?: string) => {
const path = getPathOfNode(scopeNodeId, this.state.nodes);
const nodeToExpand = treeNodeAtPath(this.state.tree!, path);
if (nodeToExpand) {
if (nodeToExpand.scopeNodeId === '' || isNodeExpandable(this.state.nodes[nodeToExpand.scopeNodeId])) {
if (!nodeToExpand.expanded || nodeToExpand.query !== query) {
const newTree = modifyTreeNodeAtPath(this.state.tree!, path, (treeNode) => {
treeNode.expanded = true;
treeNode.query = query || '';
});
this.updateState({ tree: newTree });
await this.loadNodeChildren(path, nodeToExpand, query);
}
} else {
throw new Error(`Trying to expand node at id ${scopeNodeId} that is not expandable`);
}
currentNode = parentNode.nodes[loadingNodeName];
} else {
throw new Error(`Trying to expand node at id ${scopeNodeId} not found`);
}
};
const differentQuery = currentNode.query !== query;
currentNode.expanded = expanded;
currentNode.query = query;
if (expanded || differentQuery) {
// Means we have to fetch the children of the node
private collapseNode = async (scopeNodeId: string) => {
const path = getPathOfNode(scopeNodeId, this.state.nodes);
this.updateState({ nodes, loadingNodeName });
const nodeToCollapse = treeNodeAtPath(this.state.tree!, path);
if (nodeToCollapse) {
const newTree = modifyTreeNodeAtPath(this.state.tree!, path, (treeNode) => {
treeNode.expanded = false;
treeNode.query = '';
});
this.updateState({ tree: newTree });
} else {
throw new Error(`Trying to collapse node at path or id ${scopeNodeId} not found`);
}
};
// fetchNodeApi does not throw just returns empty object.
// Load all the children of the loadingNodeName
const childNodes = await this.apiClient.fetchNode(loadingNodeName, query);
if (loadingNodeName === this.state.loadingNodeName) {
const [selectedScopes, treeScopes] = getScopesAndTreeScopesWithPaths(
this.state.selectedScopes,
this.state.treeScopes,
path,
childNodes
);
private loadNodeChildren = async (path: string[], treeNode: TreeNode, query?: string) => {
this.updateState({ loadingNodeName: treeNode.scopeNodeId });
const persistedNodes = treeScopes
.map(({ path }) => path[path.length - 1])
.filter((nodeName) => nodeName in currentNode.nodes && !(nodeName in childNodes))
.reduce<NodesMap>((acc, nodeName) => {
acc[nodeName] = {
...currentNode.nodes[nodeName],
reason: NodeReason.Persisted,
};
// We are expanding node that wasn't yet expanded so we don't have any query to filter by yet.
const childNodes = await this.apiClient.fetchNodes({ parent: treeNode.scopeNodeId, query });
return acc;
}, {});
const newNodes = { ...this.state.nodes };
currentNode.nodes = { ...persistedNodes, ...childNodes };
for (const node of childNodes) {
newNodes[node.metadata.name] = node;
}
this.updateState({ nodes, selectedScopes, treeScopes, loadingNodeName: undefined });
const newTree = modifyTreeNodeAtPath(this.state.tree!, path, (treeNode) => {
treeNode.children = {};
for (const node of childNodes) {
treeNode.children[node.metadata.name] = {
expanded: false,
scopeNodeId: node.metadata.name,
query: '',
children: undefined,
};
}
} else {
this.updateState({ nodes, loadingNodeName: undefined });
}
});
this.updateState({ tree: newTree, nodes: newNodes, loadingNodeName: undefined });
};
/**
* Toggle a selection of a scope node. Only leaf nodes representing an actual scope can be toggled so the path should
* represent such node.
*
* 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.
* Selecting a scope means we add it to a temporary list of scopes that are waiting to be applied. We make sure
* that the selection makes sense (like not allowing selection from multiple categories) and prefetch the scope.
* @param scopeNodeId
*/
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.
const newTreeScopes = this.state.treeScopes.filter((s) => s.scopeName !== node.scopeName);
if (newTreeScopes.length !== this.state.treeScopes.length) {
this.updateState({ treeScopes: newTreeScopes });
return;
}
}
public selectScope = async (scopeNodeId: string) => {
let scopeNode = this.state.nodes[scopeNodeId];
if (!node.path) {
console.warn('Node cannot be selected without both path and name', node);
return;
if (!isNodeSelectable(scopeNode)) {
throw new Error(`Trying to select node with id ${scopeNodeId} that is not selectable`);
}
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;
if (!scopeNode.spec.linkId) {
throw new Error(`Trying to select node id ${scopeNodeId} that does not have a linkId`);
}
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!);
// We prefetch the scope metadata to make sure we have it cached before we apply the scope.
this.apiClient.fetchScope(scopeNode.spec.linkId).then((scope) => {
// We don't need to wait for the update here, so we can use then instead of await.
if (scope) {
this.updateState({ scopes: { ...this.state.scopes, [scope.metadata.name]: scope } });
}
});
const treeScope: TreeScope = {
scopeName: linkId!,
path: node.path,
title,
};
// TODO: if we do global search we may not have a prent node loaded. We have the ID but there is not an API that
// would allow us to load scopeNode by ID right now so this can be undefined which means we skip the
// disableMultiSelect check.
const parentNode = this.state.nodes[scopeNode.spec.parentName!];
const selectedScope = { scopeId: scopeNode.spec.linkId, scopeNodeId: scopeNode.metadata.name };
// if something is selected we look at parent and see if we are selecting in the same category or not. As we
// cannot select in multiple categories we only need to check the first selected node. It is possible we have
// something selected without knowing the parent so we default to assuming it's not the same parent.
const sameParent =
this.state.selectedScopes[0]?.scopeNodeId &&
this.state.nodes[this.state.selectedScopes[0].scopeNodeId].spec.parentName === scopeNode.spec.parentName;
if (
!sameParent ||
// Parent says we can only select one scope at a time.
parentNode?.spec.disableMultiSelect ||
// If nothing is selected yet we just add this one.
this.state.selectedScopes.length === 0
) {
this.updateState({ selectedScopes: [selectedScope] });
} else {
this.updateState({ selectedScopes: [...this.state.selectedScopes, selectedScope] });
}
};
// 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);
/**
* Deselect a selected scope.
* @param scopeIdOrScopeNodeId This can be either a scopeId or a scopeNodeId.
*/
public deselectScope = async (scopeIdOrScopeNodeId: string) => {
const node = this.state.nodes[scopeIdOrScopeNodeId];
// This is a bit complicated because there are multiple cases where we can deselect a scope without having enough
// information.
const filter: (s: SelectedScope) => boolean = node
? // This case is when we get scopeNodeId but the selected scope can have one or the other. This happens on reload
// when we have just scopeId from the URL but then we navigate to the node in a tree and try to deselect the node.
(s) => s.scopeNodeId !== node.metadata.name && s.scopeId !== node.spec.linkId
: // This is when we scopeId, or scopeNodeId and the nodes aren't loaded yet. So we just try to match the id to the
// scopes.
(s) => s.scopeNodeId !== scopeIdOrScopeNodeId && s.scopeId !== scopeIdOrScopeNodeId;
let newSelectedScopes = this.state.selectedScopes.filter(filter);
this.updateState({ selectedScopes: newSelectedScopes });
};
this.updateState({
treeScopes: parentNode?.disableMultiSelect || !selectedFromSameNode ? [treeScope] : [...treeScopes, treeScope],
});
} else {
// We are deselecting already selected node.
treeScopes.splice(selectedIdx, 1);
this.updateState({ treeScopes });
public updateNode = async (scopeNodeId: string, expanded: boolean, query: string) => {
if (expanded) {
return this.expandOrFilterNode(scopeNodeId, query);
}
return this.collapseNode(scopeNodeId);
};
changeScopes = (scopeNames: string[]) => {
return this.setNewScopes(scopeNames.map((scopeName) => ({ scopeName, path: [], title: scopeName })));
return this.applyScopes(scopeNames.map((id) => ({ scopeId: id })));
};
/**
* Apply the selected scopes. Apart from setting the scopes it also fetches the scope metadata and also loads the
* related dashboards.
* @param treeScopes The scopes to be applied. If not provided the treeScopes state is used which was populated
* before for example by toggling the scopes in the scoped tree UI.
*/
private setNewScopes = async (treeScopes = this.state.treeScopes) => {
const newNames = treeScopes.map(({ scopeName }) => scopeName);
const currentNames = this.state.selectedScopes.map((scope) => scope.scope.metadata.name);
if (isEqual(newNames, currentNames)) {
private applyScopes = async (scopes: SelectedScope[]) => {
// Skip if we are trying to apply the same scopes as are already applied.
if (
this.state.appliedScopes.length === scopes.length &&
this.state.appliedScopes.every((selectedScope) => scopes.find((s) => selectedScope.scopeId === s.scopeId))
) {
return;
}
let selectedScopes = treeScopes.map(({ scopeName, path, title }) => ({
scope: getEmptyScopeObject(scopeName, title),
path,
}));
// Apply the scopes right away even though we don't have the metadata yet.
this.updateState({ selectedScopes, treeScopes, loading: true });
this.updateState({ appliedScopes: scopes, selectedScopes: scopes, loading: scopes.length > 0 });
// Fetches both dashboards and scope navigations
this.dashboardsService.fetchDashboards(selectedScopes.map(({ scope }) => scope.metadata.name));
if (treeScopes.length > 0) {
selectedScopes = await this.apiClient.fetchMultipleScopes(treeScopes);
if (selectedScopes.length > 0) {
this.addRecentScopes(selectedScopes);
// We call this even if we have 0 scope because in that case it also closes the dashboard drawer.
this.dashboardsService.fetchDashboards(scopes.map((s) => s.scopeId));
if (scopes.length > 0) {
const fetchedScopes = await this.apiClient.fetchMultipleScopes(scopes.map((s) => s.scopeId));
const newScopesState = { ...this.state.scopes };
for (const scope of fetchedScopes) {
newScopesState[scope.metadata.name] = scope;
}
this.addRecentScopes(fetchedScopes);
this.updateState({ scopes: newScopesState, 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([]);
public removeAllScopes = () => this.applyScopes([]);
private addRecentScopes = (scopes: SelectedScope[]) => {
private addRecentScopes = (scopes: Scope[]) => {
if (scopes.length === 0) {
return;
}
@ -263,50 +260,54 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
const recentScopes = this.getRecentScopes();
recentScopes.unshift(scopes);
localStorage.setItem(RECENT_SCOPES_KEY, JSON.stringify(recentScopes.slice(0, RECENT_SCOPES_MAX_LENGTH - 1)));
this.store.set(RECENT_SCOPES_KEY, JSON.stringify(recentScopes.slice(0, RECENT_SCOPES_MAX_LENGTH - 1)));
};
public getRecentScopes = (): SelectedScope[][] => {
const recentScopes = JSON.parse(localStorage.getItem(RECENT_SCOPES_KEY) || '[]');
// TODO: Make type safe
/**
* Returns recent scopes from local storage. It is array of array cause each item can represent application of
* multiple different scopes.
*/
public getRecentScopes = (): Scope[][] => {
const content: string | undefined = this.store.get(RECENT_SCOPES_KEY);
const recentScopes = parseScopesFromLocalStorage(content);
// Filter out the current selection from recent scopes to avoid duplicates
const filteredScopes = recentScopes.filter((scopes: SelectedScope[]) => {
if (scopes.length !== this.state.selectedScopes.length) {
return recentScopes.filter((scopes: Scope[]) => {
if (scopes.length !== this.state.appliedScopes.length) {
return true;
}
const scopeSet = new Set(scopes.map((s) => s.scope.metadata.name));
return !this.state.selectedScopes.every((s) => scopeSet.has(s.scope.metadata.name));
const scopeSet = new Set(scopes.map((s) => s.metadata.name));
return !this.state.appliedScopes.every((s) => scopeSet.has(s.scopeId));
});
return filteredScopes.map((scopes: SelectedScope[]) => scopes);
};
/**
* Opens the scopes selector drawer and loads the root nodes if they are not loaded yet.
*/
public open = async () => {
if (Object.keys(this.state.nodes[''].nodes).length === 0) {
await this.updateNode([''], true, '');
if (!this.state.tree?.children || Object.keys(this.state.tree?.children).length === 0) {
await this.expandOrFilterNode('');
}
let nodes = { ...this.state.nodes };
// First close all nodes
nodes = closeNodes(nodes);
let newTree = closeNodes(this.state.tree!);
// Extract the path of a scope
let path = [...(this.state.selectedScopes[0]?.path ?? ['', ''])];
path.splice(path.length - 1, 1);
if (this.state.selectedScopes.length && this.state.selectedScopes[0].scopeNodeId) {
let path = getPathOfNode(this.state.selectedScopes[0].scopeNodeId, this.state.nodes);
// we want to expand the nodes parent not the node itself
path = path.slice(0, path.length - 1);
// Expand the nodes to the selected scope
nodes = expandNodes(nodes, path);
// Expand the nodes to the selected scope
newTree = expandNodes(newTree, path);
}
this.updateState({ nodes, opened: true });
this.resetSelection();
this.updateState({ tree: newTree, opened: true });
};
public closeAndReset = () => {
// Reset the treeScopes if we don't want them actually applied.
this.updateState({ opened: false, treeScopes: getTreeScopesFromSelectedScopes(this.state.selectedScopes) });
this.updateState({ opened: false });
this.resetSelection();
};
public closeAndApply = () => {
@ -315,117 +316,58 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
};
public apply = () => {
return this.setNewScopes();
return this.applyScopes(this.state.selectedScopes);
};
public resetSelection = () => {
this.updateState({ treeScopes: getTreeScopesFromSelectedScopes(this.state.selectedScopes) });
this.updateState({ selectedScopes: [...this.state.appliedScopes] });
};
}
/**
* Creates a deep copy of the node tree with expanded prop set to false.
* @param nodes
*/
function closeNodes(nodes: NodesMap): NodesMap {
return Object.entries(nodes).reduce<NodesMap>((acc, [id, node]) => {
acc[id] = {
...node,
expanded: false,
nodes: closeNodes(node.nodes),
};
return acc;
}, {});
public searchAllNodes = async (query: string, limit: number) => {
const scopeNodes = await this.apiClient.fetchNodes({ query, limit });
const newNodes = { ...this.state.nodes };
for (const node of scopeNodes) {
newNodes[node.metadata.name] = node;
}
this.updateState({ nodes: newNodes });
return scopeNodes;
};
}
function getTreeScopesFromSelectedScopes(scopes: SelectedScope[]): TreeScope[] {
return scopes.map(({ scope, path }) => ({
scopeName: scope.metadata.name,
path,
title: scope.spec.title,
}));
function isScopeLocalStorageV1(obj: unknown): obj is { scope: Scope } {
return typeof obj === 'object' && obj !== null && 'scope' in obj && isScopeObj(obj['scope']);
}
// helper func to get the selected/tree scopes together with their paths
// needed to maintain selected scopes in tree for example when navigating
// between categories or when loading scopes from URL to find the scope's path
function getScopesAndTreeScopesWithPaths(
selectedScopes: SelectedScope[],
treeScopes: TreeScope[],
path: string[],
childNodes: NodesMap
): [SelectedScope[], TreeScope[]] {
const childNodesArr = Object.values(childNodes);
// Get all scopes without paths
// We use tree scopes as the list is always up to date as opposed to selected scopes which can be outdated
const scopeNamesWithoutPaths = treeScopes.filter(({ path }) => path.length === 0).map(({ scopeName }) => scopeName);
// We search for the path of each scope name without a path
const scopeNamesWithPaths = scopeNamesWithoutPaths.reduce<Record<string, string[]>>((acc, scopeName) => {
const possibleParent = childNodesArr.find((childNode) => childNode.selectable && childNode.linkId === scopeName);
if (possibleParent) {
acc[scopeName] = [...path, possibleParent.name];
}
return acc;
}, {});
// Update the paths of the selected scopes based on what we found
const newSelectedScopes = selectedScopes.map((selectedScope) => {
if (selectedScope.path.length > 0) {
return selectedScope;
}
return {
...selectedScope,
path: scopeNamesWithPaths[selectedScope.scope.metadata.name] ?? [],
};
});
// Update the paths of the tree scopes based on what we found
const newTreeScopes = treeScopes.map((treeScope) => {
if (treeScope.path.length > 0) {
return treeScope;
}
return {
...treeScope,
path: scopeNamesWithPaths[treeScope.scopeName] ?? [],
};
});
return [newSelectedScopes, newTreeScopes];
function isScopeObj(obj: unknown): obj is Scope {
return (
typeof obj === 'object' &&
obj !== null &&
'metadata' in obj &&
typeof obj['metadata'] === 'object' &&
obj['metadata'] !== null &&
'name' in obj['metadata'] &&
'spec' in obj
);
}
function expandNodes(nodes: NodesMap, path: string[]): NodesMap {
nodes = { ...nodes };
let currentNodes = nodes;
for (let i = 0; i < path.length; i++) {
const nodeId = path[i];
currentNodes[nodeId] = {
...currentNodes[nodeId],
expanded: true,
};
currentNodes = currentNodes[nodeId].nodes;
function parseScopesFromLocalStorage(content: string | undefined): Scope[][] {
let recentScopes;
try {
recentScopes = JSON.parse(content || '[]');
} catch (e) {
console.error('Failed to parse recent scopes', e, content);
return [];
}
if (!(Array.isArray(recentScopes) && Array.isArray(recentScopes[0]))) {
return [];
}
return nodes;
}
function getNodesAtPath(node: Node, path: string[]): Node | undefined {
let currentNode = node;
for (const section of path) {
if (currentNode === undefined) {
return undefined;
}
currentNode = currentNode.nodes[section];
if (isScopeLocalStorageV1(recentScopes[0]?.[0])) {
// Backward compatibility
recentScopes = recentScopes.map((s: Array<{ scope: Scope }>) => s.map((scope) => scope.scope));
} else if (!isScopeObj(recentScopes[0]?.[0])) {
return [];
}
return currentNode;
return recentScopes;
}

@ -1,97 +1,126 @@
import { Dictionary, groupBy } from 'lodash';
import { useMemo } from 'react';
import { css } from '@emotion/css';
import Skeleton from 'react-loading-skeleton';
import { GrafanaTheme2, Scope } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
import { RecentScopes } from './RecentScopes';
import { ScopesTreeHeadline } from './ScopesTreeHeadline';
import { ScopesTreeItem } from './ScopesTreeItem';
import { ScopesTreeLoading } from './ScopesTreeLoading';
import { ScopesTreeItemList } from './ScopesTreeItemList';
import { ScopesTreeSearch } from './ScopesTreeSearch';
import { Node, NodeReason, NodesMap, OnNodeSelectToggle, OnNodeUpdate, TreeScope, SelectedScope } from './types';
import { NodesMap, SelectedScope, TreeNode } from './types';
export interface ScopesTreeProps {
nodes: NodesMap;
nodePath: string[];
tree: TreeNode;
loadingNodeName: string | undefined;
scopes: TreeScope[];
onNodeUpdate: OnNodeUpdate;
onNodeSelectToggle: OnNodeSelectToggle;
selectedScopes: SelectedScope[];
scopeNodes: NodesMap;
onNodeUpdate: (scopeNodeId: string, expanded: boolean, query: string) => void;
selectScope: (scopeNodeId: string) => void;
deselectScope: (scopeNodeId: string) => void;
// Recent scopes are only shown at the root node
recentScopes?: SelectedScope[][];
onRecentScopesSelect?: (recentScopeSet: SelectedScope[]) => void;
recentScopes?: Scope[][];
onRecentScopesSelect?: (scopeIds: string[]) => void;
}
export function ScopesTree({
nodes,
nodePath,
tree,
loadingNodeName,
scopes,
selectedScopes,
recentScopes,
onRecentScopesSelect,
onNodeUpdate,
onNodeSelectToggle,
scopeNodes,
selectScope,
deselectScope,
}: ScopesTreeProps) {
const nodeId = nodePath[nodePath.length - 1];
const node = nodes[nodeId];
const childNodes = Object.values(node.nodes);
const nodeLoading = loadingNodeName === nodeId;
const scopeNames = scopes.map(({ scopeName }) => scopeName);
const anyChildExpanded = childNodes.some(({ expanded }) => expanded);
const groupedNodes: Dictionary<Node[]> = useMemo(() => groupBy(childNodes, 'reason'), [childNodes]);
const lastExpandedNode = !anyChildExpanded && node.expanded;
const styles = useStyles2(getStyles);
const nodeLoading = loadingNodeName === tree.scopeNodeId;
const children = tree.children;
let childrenArray = Object.values(children || {});
const anyChildExpanded = childrenArray.some(({ expanded }) => expanded);
// Nodes that are already selected (not applied) are always shown if we are in their category, even if they are
// filtered out by query filter
let selectedNodesToShow: TreeNode[] = [];
if (selectedScopes.length > 0 && selectedScopes[0].scopeNodeId) {
if (tree.scopeNodeId === scopeNodes[selectedScopes[0].scopeNodeId]?.spec.parentName) {
selectedNodesToShow = selectedScopes
// We filter out those which are still shown in the normal list of results
.filter((s) => !childrenArray.map((c) => c.scopeNodeId).includes(s.scopeNodeId!))
.map((s) => ({
// Because we had to check the parent with the use of scopeNodeId we know we have it. (we may not have it
// if the selected scopes are from url persistence, in which case we don't show them)
scopeNodeId: s.scopeNodeId!,
query: '',
expanded: false,
}));
}
}
const lastExpandedNode = !anyChildExpanded && tree.expanded;
return (
<>
<ScopesTreeSearch
anyChildExpanded={anyChildExpanded}
nodePath={nodePath}
query={node.query}
onNodeUpdate={onNodeUpdate}
/>
{nodePath.length === 1 &&
nodePath[0] === '' &&
<ScopesTreeSearch anyChildExpanded={anyChildExpanded} onNodeUpdate={onNodeUpdate} treeNode={tree} />
{tree.scopeNodeId === '' &&
!anyChildExpanded &&
recentScopes &&
recentScopes.length > 0 &&
onRecentScopesSelect &&
!node.query && <RecentScopes recentScopes={recentScopes} onSelect={onRecentScopesSelect} />}
<ScopesTreeLoading nodeLoading={nodeLoading}>
<ScopesTreeItem
anyChildExpanded={anyChildExpanded}
groupedNodes={groupedNodes}
lastExpandedNode={lastExpandedNode}
loadingNodeName={loadingNodeName}
node={node}
nodePath={nodePath}
nodeReason={NodeReason.Persisted}
scopes={scopes}
scopeNames={scopeNames}
type="persisted"
onNodeSelectToggle={onNodeSelectToggle}
onNodeUpdate={onNodeUpdate}
/>
<ScopesTreeHeadline
anyChildExpanded={anyChildExpanded}
query={node.query}
resultsNodes={groupedNodes[NodeReason.Result] ?? []}
/>
<ScopesTreeItem
anyChildExpanded={anyChildExpanded}
groupedNodes={groupedNodes}
lastExpandedNode={lastExpandedNode}
loadingNodeName={loadingNodeName}
node={node}
nodePath={nodePath}
nodeReason={NodeReason.Result}
scopes={scopes}
scopeNames={scopeNames}
type="result"
onNodeSelectToggle={onNodeSelectToggle}
onNodeUpdate={onNodeUpdate}
/>
</ScopesTreeLoading>
!tree.query && <RecentScopes recentScopes={recentScopes} onSelect={onRecentScopesSelect} />}
{nodeLoading ? (
<Skeleton count={5} className={styles.loader} />
) : (
<>
<ScopesTreeItemList
items={selectedNodesToShow}
anyChildExpanded={anyChildExpanded}
lastExpandedNode={lastExpandedNode}
loadingNodeName={loadingNodeName}
onNodeUpdate={onNodeUpdate}
selectedScopes={selectedScopes}
scopeNodes={scopeNodes}
selectScope={selectScope}
deselectScope={deselectScope}
maxHeight={`${Math.min(5, selectedNodesToShow.length) * 30}px`}
/>
<ScopesTreeHeadline
anyChildExpanded={anyChildExpanded}
query={tree.query}
resultsNodes={childrenArray}
scopeNodes={scopeNodes}
/>
<ScopesTreeItemList
items={childrenArray}
anyChildExpanded={anyChildExpanded}
lastExpandedNode={lastExpandedNode}
loadingNodeName={loadingNodeName}
onNodeUpdate={onNodeUpdate}
selectedScopes={selectedScopes}
scopeNodes={scopeNodes}
selectScope={selectScope}
deselectScope={deselectScope}
maxHeight={'100%'}
/>
</>
)}
</>
);
}
const getStyles = (theme: GrafanaTheme2) => {
return {
loader: css({
margin: theme.spacing(0.5, 0),
}),
};
};

@ -4,17 +4,22 @@ import { GrafanaTheme2 } from '@grafana/data';
import { Trans } from '@grafana/i18n';
import { useStyles2 } from '@grafana/ui';
import { Node } from './types';
import { NodesMap, TreeNode } from './types';
export interface ScopesTreeHeadlineProps {
anyChildExpanded: boolean;
query: string;
resultsNodes: Node[];
resultsNodes: TreeNode[];
scopeNodes: NodesMap;
}
export function ScopesTreeHeadline({ anyChildExpanded, query, resultsNodes }: ScopesTreeHeadlineProps) {
export function ScopesTreeHeadline({ anyChildExpanded, query, resultsNodes, scopeNodes }: ScopesTreeHeadlineProps) {
const styles = useStyles2(getStyles);
if (anyChildExpanded || (resultsNodes.some((n) => n.nodeType === 'container') && !query)) {
if (
anyChildExpanded ||
(resultsNodes.some((n) => scopeNodes[n.scopeNodeId].spec.nodeType === 'container') && !query)
) {
return null;
}

@ -1,144 +1,119 @@
import { css, cx } from '@emotion/css';
import { Dictionary } from 'lodash';
import { GrafanaTheme2 } from '@grafana/data';
import { useTranslate } from '@grafana/i18n';
import { Checkbox, Icon, RadioButtonDot, ScrollContainer, useStyles2 } from '@grafana/ui';
import { Checkbox, Icon, RadioButtonDot, useStyles2 } from '@grafana/ui';
import { ScopesTree } from './ScopesTree';
import { Node, NodeReason, OnNodeSelectToggle, OnNodeUpdate, TreeScope } from './types';
import { isNodeExpandable, isNodeSelectable } from './scopesTreeUtils';
import { NodesMap, SelectedScope, TreeNode } from './types';
export interface ScopesTreeItemProps {
anyChildExpanded: boolean;
groupedNodes: Dictionary<Node[]>;
lastExpandedNode: boolean;
loadingNodeName: string | undefined;
node: Node;
nodePath: string[];
nodeReason: NodeReason;
scopeNames: string[];
scopes: TreeScope[];
type: 'persisted' | 'result';
onNodeUpdate: OnNodeUpdate;
onNodeSelectToggle: OnNodeSelectToggle;
treeNode: TreeNode;
scopeNodes: NodesMap;
selected: boolean;
selectedScopes: SelectedScope[];
onNodeUpdate: (scopeNodeId: string, expanded: boolean, query: string) => void;
selectScope: (scopeNodeId: string) => void;
deselectScope: (scopeNodeId: string) => void;
}
export function ScopesTreeItem({
anyChildExpanded,
groupedNodes,
lastExpandedNode,
loadingNodeName,
node,
nodePath,
nodeReason,
scopeNames,
scopes,
type,
onNodeSelectToggle,
treeNode,
onNodeUpdate,
scopeNodes,
selected,
selectedScopes,
selectScope,
deselectScope,
}: ScopesTreeItemProps) {
const styles = useStyles2(getStyles);
const { t } = useTranslate();
const nodes = groupedNodes[nodeReason] || [];
if (nodes.length === 0) {
if (anyChildExpanded && !treeNode.expanded) {
return null;
}
const children = (
<div role="tree" className={anyChildExpanded ? styles.expandedContainer : undefined}>
{nodes.map((childNode) => {
const selected = childNode.selectable && scopeNames.includes(childNode.linkId!);
if (anyChildExpanded && !childNode.expanded) {
return null;
}
const childNodePath = [...nodePath, childNode.name];
const radioName = childNodePath.join('.');
return (
<div
key={childNode.name}
role="treeitem"
aria-selected={childNode.expanded}
className={anyChildExpanded ? styles.expandedContainer : undefined}
const scopeNode = scopeNodes[treeNode.scopeNodeId];
if (!scopeNode) {
// Should not happen as only way we show a tree is if we also load the nodes.
return null;
}
const parentNode = scopeNode.spec.parentName ? scopeNodes[scopeNode.spec.parentName] : undefined;
const disableMultiSelect = parentNode?.spec.disableMultiSelect ?? false;
const isSelectable = isNodeSelectable(scopeNode);
const isExpandable = isNodeExpandable(scopeNode);
return (
<div
key={treeNode.scopeNodeId}
role="treeitem"
aria-selected={treeNode.expanded}
className={anyChildExpanded ? styles.expandedContainer : undefined}
>
<div className={cx(styles.title, isSelectable && !treeNode.expanded && styles.titlePadding)}>
{isSelectable && !treeNode.expanded ? (
disableMultiSelect ? (
<RadioButtonDot
id={treeNode.scopeNodeId}
name={treeNode.scopeNodeId}
checked={selected}
label=""
data-testid={`scopes-tree-${treeNode.scopeNodeId}-radio`}
onClick={() => {
selected ? deselectScope(treeNode.scopeNodeId) : selectScope(treeNode.scopeNodeId);
}}
/>
) : (
<Checkbox
checked={selected}
data-testid={`scopes-tree-${treeNode.scopeNodeId}-checkbox`}
onChange={() => {
selected ? deselectScope(treeNode.scopeNodeId) : selectScope(treeNode.scopeNodeId);
}}
/>
)
) : null}
{isExpandable ? (
<button
className={styles.expand}
data-testid={`scopes-tree-${treeNode.scopeNodeId}-expand`}
aria-label={treeNode.expanded ? t('scopes.tree.collapse', 'Collapse') : t('scopes.tree.expand', 'Expand')}
onClick={() => {
onNodeUpdate(treeNode.scopeNodeId, !treeNode.expanded, treeNode.query);
}}
>
<div className={cx(styles.title, childNode.selectable && !childNode.expanded && styles.titlePadding)}>
{childNode.selectable && !childNode.expanded ? (
node.disableMultiSelect ? (
<RadioButtonDot
id={radioName}
name={radioName}
checked={selected}
label=""
data-testid={`scopes-tree-${type}-${childNode.name}-radio`}
onClick={() => {
onNodeSelectToggle({ path: childNodePath });
}}
/>
) : (
<Checkbox
checked={selected}
data-testid={`scopes-tree-${type}-${childNode.name}-checkbox`}
onChange={() => {
onNodeSelectToggle({ path: childNodePath });
}}
/>
)
) : null}
{childNode.expandable ? (
<button
className={styles.expand}
data-testid={`scopes-tree-${type}-${childNode.name}-expand`}
aria-label={
childNode.expanded ? t('scopes.tree.collapse', 'Collapse') : t('scopes.tree.expand', 'Expand')
}
onClick={() => {
onNodeUpdate(childNodePath, !childNode.expanded, childNode.query);
}}
>
<Icon name={!childNode.expanded ? 'angle-right' : 'angle-down'} />
{childNode.title}
</button>
) : (
<span data-testid={`scopes-tree-${type}-${childNode.name}-title`}>{childNode.title}</span>
)}
</div>
<div className={styles.children}>
{childNode.expanded && (
<ScopesTree
nodes={node.nodes}
nodePath={childNodePath}
loadingNodeName={loadingNodeName}
scopes={scopes}
onNodeUpdate={onNodeUpdate}
onNodeSelectToggle={onNodeSelectToggle}
/>
)}
</div>
</div>
);
})}
<Icon name={!treeNode.expanded ? 'angle-right' : 'angle-down'} />
{scopeNode.spec.title}
</button>
) : (
<span data-testid={`scopes-tree-${treeNode.scopeNodeId}-title`}>{scopeNode.spec.title}</span>
)}
</div>
<div className={styles.children}>
{treeNode.expanded && (
<ScopesTree
tree={treeNode}
loadingNodeName={loadingNodeName}
onNodeUpdate={onNodeUpdate}
scopeNodes={scopeNodes}
selectedScopes={selectedScopes}
selectScope={selectScope}
deselectScope={deselectScope}
/>
)}
</div>
</div>
);
if (lastExpandedNode) {
return (
<ScrollContainer
minHeight={`${Math.min(5, nodes.length) * 30}px`}
maxHeight={nodeReason === NodeReason.Persisted ? `${Math.min(5, nodes.length) * 30}px` : '100%'}
>
{children}
</ScrollContainer>
);
}
return children;
}
const getStyles = (theme: GrafanaTheme2) => {

@ -0,0 +1,92 @@
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { ScrollContainer, useStyles2 } from '@grafana/ui';
import { ScopesTreeItem } from './ScopesTreeItem';
import { isNodeSelectable } from './scopesTreeUtils';
import { NodesMap, SelectedScope, TreeNode } from './types';
type Props = {
anyChildExpanded: boolean;
lastExpandedNode: boolean;
loadingNodeName: string | undefined;
items: TreeNode[];
maxHeight: string;
selectedScopes: SelectedScope[];
scopeNodes: NodesMap;
onNodeUpdate: (scopeNodeId: string, expanded: boolean, query: string) => void;
selectScope: (scopeNodeId: string) => void;
deselectScope: (scopeNodeId: string) => void;
};
export function ScopesTreeItemList({
items,
anyChildExpanded,
lastExpandedNode,
maxHeight,
selectedScopes,
scopeNodes,
loadingNodeName,
onNodeUpdate,
selectScope,
deselectScope,
}: Props) {
const styles = useStyles2(getStyles);
if (items.length === 0) {
return null;
}
const children = (
<div role="tree" className={anyChildExpanded ? styles.expandedContainer : undefined}>
{items.map((childNode) => {
const selected =
isNodeSelectable(scopeNodes[childNode.scopeNodeId]) &&
selectedScopes.some((s) => {
if (s.scopeNodeId) {
// If we have scopeNodeId we only match based on that so even if the actual scope is the same we don't
// mark different scopeNode as selected.
return s.scopeNodeId === childNode.scopeNodeId;
} else {
return s.scopeId === scopeNodes[childNode.scopeNodeId]?.spec.linkId;
}
});
return (
<ScopesTreeItem
key={childNode.scopeNodeId}
treeNode={childNode}
selected={selected}
selectedScopes={selectedScopes}
scopeNodes={scopeNodes}
loadingNodeName={loadingNodeName}
anyChildExpanded={anyChildExpanded}
onNodeUpdate={onNodeUpdate}
selectScope={selectScope}
deselectScope={deselectScope}
/>
);
})}
</div>
);
if (lastExpandedNode) {
return (
<ScrollContainer minHeight={`${Math.min(5, items.length) * 30}px`} maxHeight={maxHeight}>
{children}
</ScrollContainer>
);
}
return children;
}
const getStyles = (theme: GrafanaTheme2) => {
return {
expandedContainer: css({
display: 'flex',
flexDirection: 'column',
maxHeight: '100%',
}),
};
};

@ -1,29 +0,0 @@
import { css } from '@emotion/css';
import { ReactNode } from 'react';
import Skeleton from 'react-loading-skeleton';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
export interface ScopesTreeLoadingProps {
children: ReactNode;
nodeLoading: boolean;
}
export function ScopesTreeLoading({ children, nodeLoading }: ScopesTreeLoadingProps) {
const styles = useStyles2(getStyles);
if (nodeLoading) {
return <Skeleton count={5} className={styles.loader} />;
}
return children;
}
const getStyles = (theme: GrafanaTheme2) => {
return {
loader: css({
margin: theme.spacing(0.5, 0),
}),
};
};

@ -6,30 +6,32 @@ import { GrafanaTheme2 } from '@grafana/data';
import { useTranslate } from '@grafana/i18n';
import { FilterInput, useStyles2 } from '@grafana/ui';
import { OnNodeUpdate } from './types';
import { TreeNode } from './types';
export interface ScopesTreeSearchProps {
anyChildExpanded: boolean;
nodePath: string[];
query: string;
onNodeUpdate: OnNodeUpdate;
treeNode: TreeNode;
onNodeUpdate: (scopeNodeId: string, expanded: boolean, query: string) => void;
}
export function ScopesTreeSearch({ anyChildExpanded, nodePath, query, onNodeUpdate }: ScopesTreeSearchProps) {
export function ScopesTreeSearch({ anyChildExpanded, treeNode, onNodeUpdate }: ScopesTreeSearchProps) {
const styles = useStyles2(getStyles);
const [inputState, setInputState] = useState<{ value: string; dirty: boolean }>({ value: query, dirty: false });
const [inputState, setInputState] = useState<{ value: string; dirty: boolean }>({
value: treeNode.query,
dirty: false,
});
useEffect(() => {
if (!inputState.dirty && inputState.value !== query) {
setInputState({ value: query, dirty: false });
if (!inputState.dirty && inputState.value !== treeNode.query) {
setInputState({ value: treeNode.query, dirty: false });
}
}, [inputState, query]);
}, [inputState, treeNode.query]);
useDebounce(
() => {
if (inputState.dirty) {
onNodeUpdate(nodePath, true, inputState.value);
onNodeUpdate(treeNode.scopeNodeId, true, inputState.value);
}
},
500,

@ -0,0 +1,206 @@
import { ScopeNode } from '@grafana/data';
import {
closeNodes,
expandNodes,
isNodeExpandable,
isNodeSelectable,
getPathOfNode,
modifyTreeNodeAtPath,
treeNodeAtPath,
} from './scopesTreeUtils';
import { TreeNode, NodesMap } from './types';
describe('scopesTreeUtils', () => {
describe('closeNodes', () => {
it('should create a deep copy with all nodes closed', () => {
const tree: TreeNode = {
expanded: true,
scopeNodeId: 'root',
query: '',
children: {
child1: {
expanded: true,
scopeNodeId: 'child1',
query: '',
children: {
grandchild1: {
expanded: true,
scopeNodeId: 'grandchild1',
query: '',
},
},
},
},
};
const result = closeNodes(tree);
expect(result.expanded).toBe(false);
expect(result.children?.child1.expanded).toBe(false);
expect(result.children?.child1.children?.grandchild1.expanded).toBe(false);
// Verify it's a deep copy
expect(result).not.toBe(tree);
expect(result.children).not.toBe(tree.children);
});
});
describe('expandNodes', () => {
it('should expand nodes along the specified path', () => {
const tree: TreeNode = {
expanded: false,
scopeNodeId: 'root',
query: '',
children: {
child1: {
expanded: false,
scopeNodeId: 'child1',
query: '',
children: {
grandchild1: {
expanded: false,
scopeNodeId: 'grandchild1',
query: '',
},
grandchild2: {
expanded: false,
scopeNodeId: 'grandchild2',
query: '',
},
},
},
},
};
const path = ['', 'child1', 'grandchild1'];
const result = expandNodes(tree, path);
expect(result.expanded).toBe(true);
expect(result.children?.child1.expanded).toBe(true);
expect(result.children?.child1.children?.grandchild1.expanded).toBe(true);
// Other nodes don't get expanded
expect(result.children?.child1.children?.grandchild2.expanded).toBe(false);
});
it('should throw error when path contains non-existent node', () => {
const tree: TreeNode = {
expanded: false,
scopeNodeId: 'root',
query: '',
children: {},
};
expect(() => expandNodes(tree, ['', 'nonexistent'])).toThrow('Node nonexistent not found in tree');
});
});
describe('isNodeExpandable', () => {
it('should return true for container nodes', () => {
const node = { spec: { nodeType: 'container' } } as ScopeNode;
expect(isNodeExpandable(node)).toBe(true);
});
it('should return false for non-container nodes', () => {
const node = { spec: { nodeType: 'leaf' } } as ScopeNode;
expect(isNodeExpandable(node)).toBe(false);
});
});
describe('isNodeSelectable', () => {
it('should return true for scope nodes', () => {
const node = { spec: { linkType: 'scope' } } as ScopeNode;
expect(isNodeSelectable(node)).toBe(true);
});
it('should return false for non-scope nodes', () => {
const node = { spec: { linkType: undefined } } as ScopeNode;
expect(isNodeSelectable(node)).toBe(false);
});
});
describe('getPathOfNode', () => {
it('should return correct path for nested node', () => {
const nodes: NodesMap = {
root: { spec: { parentName: '' } } as ScopeNode,
child: { spec: { parentName: 'root' } } as ScopeNode,
grandchild: { spec: { parentName: 'child' } } as ScopeNode,
};
const path = getPathOfNode('grandchild', nodes);
expect(path).toEqual(['', 'root', 'child', 'grandchild']);
});
});
describe('modifyTreeNodeAtPath', () => {
it('should modify node at specified path', () => {
const tree: TreeNode = {
expanded: false,
scopeNodeId: 'root',
query: '',
children: {
child1: {
expanded: false,
scopeNodeId: 'child1',
query: '',
},
},
};
const result = modifyTreeNodeAtPath(tree, ['', 'child1'], (node) => {
node.expanded = true;
node.query = 'test';
});
expect(result.children?.child1.expanded).toBe(true);
expect(result.children?.child1.query).toBe('test');
});
it('should return original tree if path is invalid', () => {
const tree: TreeNode = {
expanded: false,
scopeNodeId: 'root',
query: '',
children: {},
};
const result = modifyTreeNodeAtPath(tree, ['', 'nonexistent'], (node) => {
node.expanded = true;
});
expect(result).toEqual(tree);
});
});
describe('treeNodeAtPath', () => {
it('should return node at specified path', () => {
const tree: TreeNode = {
expanded: false,
scopeNodeId: 'root',
query: '',
children: {
child1: {
expanded: false,
scopeNodeId: 'child1',
query: '',
},
},
};
const result = treeNodeAtPath(tree, ['', 'child1']);
expect(result).toBe(tree.children?.child1);
});
it('should return undefined for invalid path', () => {
const tree: TreeNode = {
expanded: false,
scopeNodeId: 'root',
query: '',
children: {},
};
const result = treeNodeAtPath(tree, ['', 'nonexistent']);
expect(result).toBeUndefined();
});
});
});

@ -0,0 +1,111 @@
import { ScopeNode } from '@grafana/data';
import { NodesMap, TreeNode } from './types';
/**
* Creates a deep copy of the node tree with expanded prop set to false.
*/
export function closeNodes(tree: TreeNode): TreeNode {
const node = { ...tree };
node.expanded = false;
if (node.children) {
node.children = { ...node.children };
for (const key of Object.keys(node.children)) {
node.children[key] = closeNodes(node.children[key]);
}
}
return node;
}
export function expandNodes(tree: TreeNode, path: string[]): TreeNode {
let newTree = { ...tree };
let currentTree = newTree;
currentTree.expanded = true;
// Remove the root segment
const newPath = path.slice(1);
for (const segment of newPath) {
const node = currentTree.children?.[segment];
if (!node) {
throw new Error(`Node ${segment} not found in tree`);
}
const newNode = { ...node };
currentTree.children = { ...currentTree.children };
currentTree.children[segment] = newNode;
newNode.expanded = true;
currentTree = newNode;
}
return newTree;
}
export function isNodeExpandable(node: ScopeNode) {
return node.spec.nodeType === 'container';
}
export function isNodeSelectable(node: ScopeNode) {
return node.spec.linkType === 'scope';
}
export function getPathOfNode(scopeNodeId: string, nodes: NodesMap): string[] {
if (scopeNodeId === '') {
return [''];
}
const path = [scopeNodeId];
let parent = nodes[scopeNodeId]?.spec.parentName;
while (parent) {
path.unshift(parent);
parent = nodes[parent]?.spec.parentName;
}
path.unshift('');
return path;
}
export function modifyTreeNodeAtPath(tree: TreeNode, path: string[], modifier: (treeNode: TreeNode) => void) {
if (path.length < 1) {
return tree;
}
const newTree = { ...tree };
let currentNode = newTree;
if (path.length === 1 && path[0] === '') {
modifier(currentNode);
return newTree;
}
for (const section of path.slice(1)) {
if (!currentNode.children?.[section]) {
return newTree;
}
currentNode.children = { ...currentNode.children };
currentNode.children[section] = { ...currentNode.children[section] };
currentNode = currentNode.children[section];
}
modifier(currentNode);
return newTree;
}
export function treeNodeAtPath(tree: TreeNode, path: string[]) {
if (path.length < 1) {
return undefined;
}
if (path.length === 1 && path[0] === '') {
return tree;
}
let treeNode: TreeNode | undefined = tree;
for (const section of path.slice(1)) {
treeNode = treeNode.children?.[section];
if (!treeNode) {
return undefined;
}
}
return treeNode;
}

@ -1,35 +1,16 @@
import { Scope, ScopeNodeSpec } from '@grafana/data';
import { Scope, ScopeNode } from '@grafana/data';
export enum NodeReason {
Persisted,
Result,
}
export interface Node extends ScopeNodeSpec {
name: string;
reason: NodeReason;
expandable: boolean;
selectable: boolean;
expanded: boolean;
query: string;
nodes: NodesMap;
}
export type NodesMap = Record<string, Node>;
export type NodesMap = Record<string, ScopeNode>;
export type ScopesMap = Record<string, Scope>;
export interface SelectedScope {
scope: Scope;
path: string[];
scopeId: string;
scopeNodeId?: string;
}
export interface TreeScope {
title: string;
scopeName: string;
path: string[];
export interface TreeNode {
scopeNodeId: string;
expanded: boolean;
query: string;
children?: Record<string, TreeNode>;
}
// 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 = (node: ToggleNode) => void;

@ -1,7 +1,6 @@
import { config, locationService } from '@grafana/runtime';
import { ScopesService } from '../ScopesService';
import { ScopesSelectorService } from '../selector/ScopesSelectorService';
import {
applyScopes,
@ -22,8 +21,6 @@ import {
updateScopes,
} from './utils/actions';
import {
expectPersistedApplicationsGrafanaNotPresent,
expectPersistedApplicationsMimirNotPresent,
expectPersistedApplicationsMimirPresent,
expectResultApplicationsCloudNotPresent,
expectResultApplicationsCloudPresent,
@ -39,8 +36,6 @@ import {
expectResultCloudOpsSelected,
expectScopesHeadline,
expectScopesSelectorValue,
expectSelectedScopePath,
expectTreeScopePath,
} from './utils/assertions';
import { getDatasource, getInstanceSettings, getMock } from './utils/mocks';
import { renderDashboard, resetScenes } from './utils/render';
@ -58,7 +53,6 @@ describe('Tree', () => {
let fetchNodesSpy: jest.SpyInstance;
let fetchScopeSpy: jest.SpyInstance;
let scopesService: ScopesService;
let scopesSelectorService: ScopesSelectorService;
beforeAll(() => {
config.featureToggles.scopeFilters = true;
@ -68,8 +62,7 @@ describe('Tree', () => {
beforeEach(async () => {
const result = await renderDashboard();
scopesService = result.scopesService;
scopesSelectorService = result.scopesSelectorService;
fetchNodesSpy = jest.spyOn(result.client, 'fetchNode');
fetchNodesSpy = jest.spyOn(result.client, 'fetchNodes');
fetchScopeSpy = jest.spyOn(result.client, 'fetchScope');
});
@ -171,8 +164,6 @@ describe('Tree', () => {
await searchScopes('grafana');
expect(fetchNodesSpy).toHaveBeenCalledTimes(3);
expectPersistedApplicationsMimirPresent();
expectPersistedApplicationsGrafanaNotPresent();
expectResultApplicationsMimirNotPresent();
expectResultApplicationsGrafanaPresent();
});
@ -182,7 +173,6 @@ describe('Tree', () => {
await selectResultApplicationsMimir();
await searchScopes('mimir');
expect(fetchNodesSpy).toHaveBeenCalledTimes(3);
expectPersistedApplicationsMimirNotPresent();
expectResultApplicationsMimirPresent();
});
@ -195,8 +185,6 @@ describe('Tree', () => {
await clearScopesSearch();
expect(fetchNodesSpy).toHaveBeenCalledTimes(4);
expectPersistedApplicationsMimirNotPresent();
expectPersistedApplicationsGrafanaNotPresent();
expectResultApplicationsMimirPresent();
expectResultApplicationsGrafanaPresent();
});
@ -265,28 +253,4 @@ describe('Tree', () => {
await expandResultApplicationsCloud();
expectScopesHeadline('Recommended');
});
it('Updates the paths for scopes without paths on nodes fetching', async () => {
const selectedScopeName = 'grafana';
const unselectedScopeName = 'mimir';
const selectedScopeNameFromOtherGroup = 'dev';
await updateScopes(scopesService, [selectedScopeName, selectedScopeNameFromOtherGroup]);
expectSelectedScopePath(scopesSelectorService, selectedScopeName, []);
expectTreeScopePath(scopesSelectorService, selectedScopeName, []);
expectSelectedScopePath(scopesSelectorService, unselectedScopeName, undefined);
expectTreeScopePath(scopesSelectorService, unselectedScopeName, undefined);
expectSelectedScopePath(scopesSelectorService, selectedScopeNameFromOtherGroup, []);
expectTreeScopePath(scopesSelectorService, selectedScopeNameFromOtherGroup, []);
await openSelector();
await expandResultApplications();
const expectedPath = ['', 'applications', 'applications-grafana'];
expectSelectedScopePath(scopesSelectorService, selectedScopeName, expectedPath);
expectTreeScopePath(scopesSelectorService, selectedScopeName, expectedPath);
expectSelectedScopePath(scopesSelectorService, unselectedScopeName, undefined);
expectTreeScopePath(scopesSelectorService, unselectedScopeName, undefined);
expectSelectedScopePath(scopesSelectorService, selectedScopeNameFromOtherGroup, []);
expectTreeScopePath(scopesSelectorService, selectedScopeNameFromOtherGroup, []);
});
});

@ -1,5 +1,3 @@
import { ScopesSelectorService } from '../../selector/ScopesSelectorService';
import {
getDashboard,
getDashboardsContainer,
@ -16,10 +14,8 @@ import {
getResultApplicationsMimirSelect,
getResultCloudDevRadio,
getResultCloudOpsRadio,
getSelectedScope,
getSelectorInput,
getTreeHeadline,
getTreeScope,
queryAllDashboard,
queryDashboard,
queryDashboardFolderExpand,
@ -86,8 +82,3 @@ export const expectDashboardInDocument = (uid: string) => expectInDocument(() =>
export const expectDashboardNotInDocument = (uid: string) => expectNotInDocument(() => queryDashboard(uid));
export const expectDashboardLength = (uid: string, length: number) =>
expect(queryAllDashboard(uid)).toHaveLength(length);
export const expectSelectedScopePath = (service: ScopesSelectorService, name: string, path: string[] | undefined) =>
expect(getSelectedScope(service, name)?.path).toEqual(path);
export const expectTreeScopePath = (service: ScopesSelectorService, name: string, path: string[] | undefined) =>
expect(getTreeScope(service, name)?.path).toEqual(path);

@ -169,18 +169,17 @@ export const mocksScopeDashboardBindings: ScopeDashboardBinding[] = [
),
] as const;
export const mocksNodes: Array<ScopeNode & { parent: string }> = [
export const mocksNodes: ScopeNode[] = [
{
parent: '',
metadata: { name: 'applications' },
spec: {
nodeType: 'container',
title: 'Applications',
description: 'Application Scopes',
parentName: '',
},
},
{
parent: '',
metadata: { name: 'cloud' },
spec: {
nodeType: 'container',
@ -189,10 +188,10 @@ export const mocksNodes: Array<ScopeNode & { parent: string }> = [
disableMultiSelect: true,
linkType: 'scope',
linkId: 'cloud',
parentName: '',
},
},
{
parent: 'applications',
metadata: { name: 'applications-grafana' },
spec: {
nodeType: 'leaf',
@ -200,10 +199,10 @@ export const mocksNodes: Array<ScopeNode & { parent: string }> = [
description: 'Grafana',
linkType: 'scope',
linkId: 'grafana',
parentName: 'applications',
},
},
{
parent: 'applications',
metadata: { name: 'applications-mimir' },
spec: {
nodeType: 'leaf',
@ -211,10 +210,10 @@ export const mocksNodes: Array<ScopeNode & { parent: string }> = [
description: 'Mimir',
linkType: 'scope',
linkId: 'mimir',
parentName: 'applications',
},
},
{
parent: 'applications',
metadata: { name: 'applications-loki' },
spec: {
nodeType: 'leaf',
@ -222,10 +221,10 @@ export const mocksNodes: Array<ScopeNode & { parent: string }> = [
description: 'Loki',
linkType: 'scope',
linkId: 'loki',
parentName: 'applications',
},
},
{
parent: 'applications',
metadata: { name: 'applications-tempo' },
spec: {
nodeType: 'leaf',
@ -233,10 +232,10 @@ export const mocksNodes: Array<ScopeNode & { parent: string }> = [
description: 'Tempo',
linkType: 'scope',
linkId: 'tempo',
parentName: 'applications',
},
},
{
parent: 'applications',
metadata: { name: 'applications-cloud' },
spec: {
nodeType: 'container',
@ -244,10 +243,10 @@ export const mocksNodes: Array<ScopeNode & { parent: string }> = [
description: 'Application/Cloud Scopes',
linkType: 'scope',
linkId: 'cloud',
parentName: 'applications',
},
},
{
parent: 'applications-cloud',
metadata: { name: 'applications-cloud-dev' },
spec: {
nodeType: 'leaf',
@ -255,10 +254,10 @@ export const mocksNodes: Array<ScopeNode & { parent: string }> = [
description: 'Dev',
linkType: 'scope',
linkId: 'dev',
parentName: 'applications-cloud',
},
},
{
parent: 'applications-cloud',
metadata: { name: 'applications-cloud-ops' },
spec: {
nodeType: 'leaf',
@ -266,10 +265,10 @@ export const mocksNodes: Array<ScopeNode & { parent: string }> = [
description: 'Ops',
linkType: 'scope',
linkId: 'ops',
parentName: 'applications-cloud',
},
},
{
parent: 'applications-cloud',
metadata: { name: 'applications-cloud-prod' },
spec: {
nodeType: 'leaf',
@ -277,10 +276,10 @@ export const mocksNodes: Array<ScopeNode & { parent: string }> = [
description: 'Prod',
linkType: 'scope',
linkId: 'prod',
parentName: 'applications-cloud',
},
},
{
parent: 'cloud',
metadata: { name: 'cloud-dev' },
spec: {
nodeType: 'leaf',
@ -288,10 +287,10 @@ export const mocksNodes: Array<ScopeNode & { parent: string }> = [
description: 'Dev',
linkType: 'scope',
linkId: 'dev',
parentName: 'cloud',
},
},
{
parent: 'cloud',
metadata: { name: 'cloud-ops' },
spec: {
nodeType: 'leaf',
@ -299,10 +298,10 @@ export const mocksNodes: Array<ScopeNode & { parent: string }> = [
description: 'Ops',
linkType: 'scope',
linkId: 'ops',
parentName: 'cloud',
},
},
{
parent: 'cloud',
metadata: { name: 'cloud-prod' },
spec: {
nodeType: 'leaf',
@ -310,19 +309,19 @@ export const mocksNodes: Array<ScopeNode & { parent: string }> = [
description: 'Prod',
linkType: 'scope',
linkId: 'prod',
parentName: 'cloud',
},
},
{
parent: 'cloud',
metadata: { name: 'cloud-applications' },
spec: {
nodeType: 'container',
title: 'Applications',
description: 'Cloud/Application Scopes',
parentName: 'cloud',
},
},
{
parent: 'cloud-applications',
metadata: { name: 'cloud-applications-grafana' },
spec: {
nodeType: 'leaf',
@ -330,10 +329,10 @@ export const mocksNodes: Array<ScopeNode & { parent: string }> = [
description: 'Grafana',
linkType: 'scope',
linkId: 'grafana',
parentName: 'cloud-applications',
},
},
{
parent: 'cloud-applications',
metadata: { name: 'cloud-applications-mimir' },
spec: {
nodeType: 'leaf',
@ -341,10 +340,10 @@ export const mocksNodes: Array<ScopeNode & { parent: string }> = [
description: 'Mimir',
linkType: 'scope',
linkId: 'mimir',
parentName: 'cloud-applications',
},
},
{
parent: 'cloud-applications',
metadata: { name: 'cloud-applications-loki' },
spec: {
nodeType: 'leaf',
@ -352,10 +351,10 @@ export const mocksNodes: Array<ScopeNode & { parent: string }> = [
description: 'Loki',
linkType: 'scope',
linkId: 'loki',
parentName: 'cloud-applications',
},
},
{
parent: 'cloud-applications',
metadata: { name: 'cloud-applications-tempo' },
spec: {
nodeType: 'leaf',
@ -363,6 +362,7 @@ export const mocksNodes: Array<ScopeNode & { parent: string }> = [
description: 'Tempo',
linkType: 'scope',
linkId: 'tempo',
parentName: 'cloud-applications',
},
},
] as const;
@ -376,8 +376,8 @@ export const getMock = jest
if (url.startsWith('/apis/scope.grafana.app/v0alpha1/namespaces/default/find/scope_node_children')) {
return {
items: mocksNodes.filter(
({ parent, spec: { title } }) =>
parent === params.parent && title.toLowerCase().includes((params.query ?? '').toLowerCase())
({ spec: { title, parentName } }) =>
parentName === params.parent && title.toLowerCase().includes((params.query ?? '').toLowerCase())
),
};
}

@ -1,17 +1,16 @@
import { screen } from '@testing-library/react';
import { ScopesService } from '../../ScopesService';
import { ScopesSelectorService } from '../../selector/ScopesSelectorService';
const selectors = {
tree: {
recentScopesSection: 'scopes-selector-recent-scopes-section',
search: 'scopes-tree-search',
headline: 'scopes-tree-headline',
select: (nodeId: string, type: 'result' | 'persisted') => `scopes-tree-${type}-${nodeId}-checkbox`,
radio: (nodeId: string, type: 'result' | 'persisted') => `scopes-tree-${type}-${nodeId}-radio`,
expand: (nodeId: string, type: 'result' | 'persisted') => `scopes-tree-${type}-${nodeId}-expand`,
title: (nodeId: string, type: 'result' | 'persisted') => `scopes-tree-${type}-${nodeId}-title`,
select: (nodeId: string) => `scopes-tree-${nodeId}-checkbox`,
radio: (nodeId: string) => `scopes-tree-${nodeId}-radio`,
expand: (nodeId: string) => `scopes-tree-${nodeId}-expand`,
title: (nodeId: string) => `scopes-tree-${nodeId}-title`,
},
selector: {
input: 'scopes-selector-input',
@ -64,43 +63,33 @@ export const getNotFoundForFilterClear = () => screen.getByTestId(selectors.dash
export const getTreeSearch = () => screen.getByTestId<HTMLInputElement>(selectors.tree.search);
export const getTreeHeadline = () => screen.getByTestId(selectors.tree.headline);
export const getResultApplicationsExpand = () => screen.getByTestId(selectors.tree.expand('applications', 'result'));
export const getResultApplicationsExpand = () => screen.getByTestId(selectors.tree.expand('applications'));
export const queryResultApplicationsGrafanaSelect = () =>
screen.queryByTestId<HTMLInputElement>(selectors.tree.select('applications-grafana', 'result'));
screen.queryByTestId<HTMLInputElement>(selectors.tree.select('applications-grafana'));
export const getResultApplicationsGrafanaSelect = () =>
screen.getByTestId<HTMLInputElement>(selectors.tree.select('applications-grafana', 'result'));
screen.getByTestId<HTMLInputElement>(selectors.tree.select('applications-grafana'));
export const queryPersistedApplicationsGrafanaSelect = () =>
screen.queryByTestId<HTMLInputElement>(selectors.tree.select('applications-grafana', 'persisted'));
screen.queryByTestId<HTMLInputElement>(selectors.tree.select('applications-grafana'));
export const getPersistedApplicationsGrafanaSelect = () =>
screen.getByTestId(selectors.tree.select('applications-grafana', 'persisted'));
screen.getByTestId(selectors.tree.select('applications-grafana'));
export const queryResultApplicationsMimirSelect = () =>
screen.queryByTestId(selectors.tree.select('applications-mimir', 'result'));
screen.queryByTestId(selectors.tree.select('applications-mimir'));
export const getResultApplicationsMimirSelect = () =>
screen.getByTestId<HTMLInputElement>(selectors.tree.select('applications-mimir', 'result'));
screen.getByTestId<HTMLInputElement>(selectors.tree.select('applications-mimir'));
export const queryPersistedApplicationsMimirSelect = () =>
screen.queryByTestId(selectors.tree.select('applications-mimir', 'persisted'));
screen.queryByTestId(selectors.tree.select('applications-mimir'));
export const getPersistedApplicationsMimirSelect = () =>
screen.getByTestId(selectors.tree.select('applications-mimir', 'persisted'));
screen.getByTestId(selectors.tree.select('applications-mimir'));
export const queryResultApplicationsCloudSelect = () =>
screen.queryByTestId(selectors.tree.select('applications-cloud', 'result'));
export const getResultApplicationsCloudSelect = () =>
screen.getByTestId(selectors.tree.select('applications-cloud', 'result'));
export const getResultApplicationsCloudExpand = () =>
screen.getByTestId(selectors.tree.expand('applications-cloud', 'result'));
screen.queryByTestId(selectors.tree.select('applications-cloud'));
export const getResultApplicationsCloudSelect = () => screen.getByTestId(selectors.tree.select('applications-cloud'));
export const getResultApplicationsCloudExpand = () => screen.getByTestId(selectors.tree.expand('applications-cloud'));
export const getResultApplicationsCloudDevSelect = () =>
screen.getByTestId(selectors.tree.select('applications-cloud-dev', 'result'));
screen.getByTestId(selectors.tree.select('applications-cloud-dev'));
export const getResultCloudSelect = () => screen.getByTestId(selectors.tree.select('cloud', 'result'));
export const getResultCloudExpand = () => screen.getByTestId(selectors.tree.expand('cloud', 'result'));
export const getResultCloudDevRadio = () =>
screen.getByTestId<HTMLInputElement>(selectors.tree.radio('cloud-dev', 'result'));
export const getResultCloudOpsRadio = () =>
screen.getByTestId<HTMLInputElement>(selectors.tree.radio('cloud-ops', 'result'));
export const getResultCloudSelect = () => screen.getByTestId(selectors.tree.select('cloud'));
export const getResultCloudExpand = () => screen.getByTestId(selectors.tree.expand('cloud'));
export const getResultCloudDevRadio = () => screen.getByTestId<HTMLInputElement>(selectors.tree.radio('cloud-dev'));
export const getResultCloudOpsRadio = () => screen.getByTestId<HTMLInputElement>(selectors.tree.radio('cloud-ops'));
export const getListOfScopes = (service: ScopesService) => service.state.value;
export const getListOfSelectedScopes = (service: ScopesSelectorService) => service.state.selectedScopes;
export const getListOfTreeScopes = (service: ScopesSelectorService) => service.state.treeScopes;
export const getSelectedScope = (service: ScopesSelectorService, name: string) =>
getListOfSelectedScopes(service)?.find((selectedScope) => selectedScope.scope.metadata.name === name);
export const getTreeScope = (service: ScopesSelectorService, name: string) =>
getListOfTreeScopes(service)?.find((treeScope) => treeScope.scopeName === name);

Loading…
Cancel
Save