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>
This commit is contained in:
co-authored by
Tobias Skarhed
parent
6c9fd45837
commit
1aaf8adee4
@@ -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 scopeIsSelected = selectedScopes.some((s) => {
|
||||
if (s.scopeNodeId) {
|
||||
return s.scopeNodeId === child.metadata.name;
|
||||
} else {
|
||||
return s.scopeId === child.spec.linkId;
|
||||
}
|
||||
});
|
||||
|
||||
// Selected scopes are not shown in the list but in a separate section of the command palette.
|
||||
if (child.spec.nodeType === 'leaf' && scopeIsSelected) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const action: CommandPaletteAction = {
|
||||
id: `${parentId}/${child.name}`,
|
||||
name: child.title,
|
||||
keywords: `${child.title} ${child.name}`,
|
||||
priority: SCOPES_PRIORITY,
|
||||
parent: parentId,
|
||||
};
|
||||
|
||||
if (child.nodeType === 'leaf') {
|
||||
action.perform = () => {
|
||||
toggleNodeSelect({ scopeName: child.name, path: getScopePathFromActionId(action.id) });
|
||||
};
|
||||
}
|
||||
|
||||
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 ?? [];
|
||||
(
|
||||
await getBackendSrv().get<{ items: ScopeNode[] }>(apiUrl + `/find/scope_node_children`, {
|
||||
parent: options.parent,
|
||||
query: options.query,
|
||||
limit,
|
||||
})
|
||||
)?.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;
|
||||
}, {});
|
||||
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]);
|
||||
}, [appliedScopes]);
|
||||
|
||||
const scopesPaths = useMemo(() => {
|
||||
const pathsScopesMap = scopes.reduce<Record<string, string>>((acc, { scope, path }) => {
|
||||
let currentLevel = nodes;
|
||||
const tooltipContent =
|
||||
appliedScopes.length > 0 ? <ScopesTooltip nodes={nodes} scopes={scopes} appliedScopes={appliedScopes} /> : <></>;
|
||||
|
||||
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]);
|
||||
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']);
|
||||
it('should apply the provided scope names', async () => {
|
||||
await service.changeScopes(['test-scope']);
|
||||
expect(service.state.appliedScopes).toEqual([{ scopeId: 'test-scope' }]);
|
||||
});
|
||||
|
||||
expect(service.state.treeScopes).toEqual([
|
||||
{
|
||||
scopeName: 'test-scope',
|
||||
path: [],
|
||||
title: 'test-scope',
|
||||
},
|
||||
]);
|
||||
it('should skip update if setting same scopes as are already applied', async () => {
|
||||
const subscribeFn = jest.fn();
|
||||
const sub = service.subscribeToState(subscribeFn);
|
||||
|
||||
await service.changeScopes(['test-scope', 'recent-scope']);
|
||||
expect(service.state.appliedScopes).toEqual([{ scopeId: 'test-scope' }, { scopeId: 'recent-scope' }]);
|
||||
expect(subscribeFn).toHaveBeenCalledTimes(2);
|
||||
|
||||
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);
|
||||
|
||||
// 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);
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it('should not reload root nodes if already loaded', async () => {
|
||||
// First load the nodes
|
||||
await service.updateNode([''], true, '');
|
||||
|
||||
// Reset the mock to check if it's called again
|
||||
apiClient.fetchNode.mockClear();
|
||||
|
||||
// Open the selector
|
||||
await service.open();
|
||||
|
||||
expect(service.state.opened).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('closeAndReset', () => {
|
||||
it('should close the selector and reset treeScopes to match selectedScopes', async () => {
|
||||
// Setup: Open the selector and select a scope
|
||||
await service.open();
|
||||
|
||||
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();
|
||||
|
||||
const rootNode = service.state.nodes[''];
|
||||
rootNode.nodes['test-scope'] = {
|
||||
...mockNode,
|
||||
selectable: true,
|
||||
linkId: 'test-scope',
|
||||
};
|
||||
|
||||
service.toggleNodeSelect({ path: ['', 'test-scope'] });
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
|
||||
expect(service.state.selectedScopes).toEqual([]);
|
||||
expect(service.state.treeScopes).toEqual([]);
|
||||
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]]);
|
||||
});
|
||||
|
||||
it('should work with old version', async () => {
|
||||
await service.updateNode('', true, '');
|
||||
await service.selectScope('test-scope-node');
|
||||
await service.apply();
|
||||
storeValue[RECENT_SCOPES_KEY] = JSON.stringify([
|
||||
[{ scope: mockScope2, path: [] }],
|
||||
[{ scope: mockScope, path: [] }],
|
||||
]);
|
||||
|
||||
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;
|
||||
}
|
||||
private expandOrFilterNode = async (scopeNodeId: string, query?: string) => {
|
||||
const path = getPathOfNode(scopeNodeId, this.state.nodes);
|
||||
|
||||
// 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[''];
|
||||
const nodeToExpand = treeNodeAtPath(this.state.tree!, path);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
currentNode = parentNode.nodes[loadingNodeName];
|
||||
}
|
||||
|
||||
const differentQuery = currentNode.query !== query;
|
||||
|
||||
currentNode.expanded = expanded;
|
||||
currentNode.query = query;
|
||||
|
||||
if (expanded || differentQuery) {
|
||||
// Means we have to fetch the children of the node
|
||||
|
||||
this.updateState({ nodes, loadingNodeName });
|
||||
|
||||
// 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
|
||||
);
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
currentNode.nodes = { ...persistedNodes, ...childNodes };
|
||||
|
||||
this.updateState({ nodes, selectedScopes, treeScopes, loadingNodeName: undefined });
|
||||
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`);
|
||||
}
|
||||
} else {
|
||||
this.updateState({ nodes, loadingNodeName: undefined });
|
||||
throw new Error(`Trying to expand node at id ${scopeNodeId} not found`);
|
||||
}
|
||||
};
|
||||
|
||||
private collapseNode = async (scopeNodeId: string) => {
|
||||
const path = getPathOfNode(scopeNodeId, this.state.nodes);
|
||||
|
||||
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`);
|
||||
}
|
||||
};
|
||||
|
||||
private loadNodeChildren = async (path: string[], treeNode: TreeNode, query?: string) => {
|
||||
this.updateState({ loadingNodeName: treeNode.scopeNodeId });
|
||||
|
||||
// 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 });
|
||||
|
||||
const newNodes = { ...this.state.nodes };
|
||||
|
||||
for (const node of childNodes) {
|
||||
newNodes[node.metadata.name] = node;
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
this.updateState({ tree: newTree, nodes: newNodes, loadingNodeName: undefined });
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 selectScope = async (scopeNodeId: string) => {
|
||||
let scopeNode = this.state.nodes[scopeNodeId];
|
||||
|
||||
if (!isNodeSelectable(scopeNode)) {
|
||||
throw new Error(`Trying to select node with id ${scopeNodeId} that is not selectable`);
|
||||
}
|
||||
|
||||
if (!scopeNode.spec.linkId) {
|
||||
throw new Error(`Trying to select node id ${scopeNodeId} that does not have a 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 } });
|
||||
}
|
||||
});
|
||||
|
||||
// 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] });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Deselect a selected scope.
|
||||
* @param scopeIdOrScopeNodeId This can be either a scopeId or a 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.
|
||||
public deselectScope = async (scopeIdOrScopeNodeId: string) => {
|
||||
const node = this.state.nodes[scopeIdOrScopeNodeId];
|
||||
|
||||
const newTreeScopes = this.state.treeScopes.filter((s) => s.scopeName !== node.scopeName);
|
||||
if (newTreeScopes.length !== this.state.treeScopes.length) {
|
||||
this.updateState({ treeScopes: newTreeScopes });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!node.path) {
|
||||
console.warn('Node cannot be selected without both path and name', node);
|
||||
return;
|
||||
}
|
||||
|
||||
let treeScopes = [...this.state.treeScopes];
|
||||
const parentNode = getNodesAtPath(this.state.nodes[''], node.path.slice(1, -1));
|
||||
|
||||
if (!parentNode) {
|
||||
// Either the path is wrong or we don't have the nodes loaded yet. So let's check the selected tree nodes if we
|
||||
// can remove something based on scope name.
|
||||
const scopeName = node.path.at(-1);
|
||||
const newTreeScopes = treeScopes.filter((s) => s.scopeName !== scopeName);
|
||||
if (newTreeScopes.length !== treeScopes.length) {
|
||||
this.updateState({ treeScopes: newTreeScopes });
|
||||
} else {
|
||||
console.warn('No node found for path:', node.path);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const nodeName = node.path[node.path.length - 1];
|
||||
const { linkId, title } = parentNode.nodes[nodeName];
|
||||
|
||||
const selectedIdx = treeScopes.findIndex(({ scopeName }) => scopeName === linkId);
|
||||
|
||||
if (selectedIdx === -1) {
|
||||
// We are selecting a new node.
|
||||
|
||||
// We prefetch the scope when clicking on it. This will mean that once the selection is applied in closeAndApply()
|
||||
// we already have all the scopes in cache and don't need to fetch all of them again is multiple requests.
|
||||
this.apiClient.fetchScope(linkId!);
|
||||
|
||||
const treeScope: TreeScope = {
|
||||
scopeName: linkId!,
|
||||
path: node.path,
|
||||
title,
|
||||
};
|
||||
|
||||
// We cannot select multiple scopes with different parents only. In that case we will just deselect all the
|
||||
// others.
|
||||
const selectedFromSameNode =
|
||||
treeScopes.length === 0 ||
|
||||
Object.values(parentNode.nodes).some(({ linkId }) => linkId === treeScopes[0].scopeName);
|
||||
|
||||
this.updateState({
|
||||
treeScopes: parentNode?.disableMultiSelect || !selectedFromSameNode ? [treeScope] : [...treeScopes, treeScope],
|
||||
});
|
||||
} else {
|
||||
// We are deselecting already selected node.
|
||||
treeScopes.splice(selectedIdx, 1);
|
||||
this.updateState({ treeScopes });
|
||||
// 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 });
|
||||
};
|
||||
|
||||
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));
|
||||
// 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 (treeScopes.length > 0) {
|
||||
selectedScopes = await this.apiClient.fetchMultipleScopes(treeScopes);
|
||||
if (selectedScopes.length > 0) {
|
||||
this.addRecentScopes(selectedScopes);
|
||||
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] });
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}, {});
|
||||
function isScopeLocalStorageV1(obj: unknown): obj is { scope: Scope } {
|
||||
return typeof obj === 'object' && obj !== null && 'scope' in obj && isScopeObj(obj['scope']);
|
||||
}
|
||||
|
||||
function getTreeScopesFromSelectedScopes(scopes: SelectedScope[]): TreeScope[] {
|
||||
return scopes.map(({ scope, path }) => ({
|
||||
scopeName: scope.metadata.name,
|
||||
path,
|
||||
title: scope.spec.title,
|
||||
}));
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
// 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 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} />}
|
||||
!tree.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}
|
||||
/>
|
||||
{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={node.query}
|
||||
resultsNodes={groupedNodes[NodeReason.Result] ?? []}
|
||||
/>
|
||||
<ScopesTreeHeadline
|
||||
anyChildExpanded={anyChildExpanded}
|
||||
query={tree.query}
|
||||
resultsNodes={childrenArray}
|
||||
scopeNodes={scopeNodes}
|
||||
/>
|
||||
|
||||
<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>
|
||||
<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!);
|
||||
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;
|
||||
|
||||
if (anyChildExpanded && !childNode.expanded) {
|
||||
return null;
|
||||
}
|
||||
const isSelectable = isNodeSelectable(scopeNode);
|
||||
const isExpandable = isNodeExpandable(scopeNode);
|
||||
|
||||
const childNodePath = [...nodePath, childNode.name];
|
||||
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}
|
||||
|
||||
const radioName = childNodePath.join('.');
|
||||
|
||||
return (
|
||||
<div
|
||||
key={childNode.name}
|
||||
role="treeitem"
|
||||
aria-selected={childNode.expanded}
|
||||
className={anyChildExpanded ? styles.expandedContainer : undefined}
|
||||
{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}
|
||||
<Icon name={!treeNode.expanded ? 'angle-right' : 'angle-down'} />
|
||||
|
||||
{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'} />
|
||||
{scopeNode.spec.title}
|
||||
</button>
|
||||
) : (
|
||||
<span data-testid={`scopes-tree-${treeNode.scopeNodeId}-title`}>{scopeNode.spec.title}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{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>
|
||||
);
|
||||
})}
|
||||
<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);
|
||||
|
||||
Reference in New Issue
Block a user