diff --git a/.betterer.results b/.betterer.results index ee45e74bdab..ff92a6b8cc8 100644 --- a/.betterer.results +++ b/.betterer.results @@ -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"] ], diff --git a/packages/grafana-data/src/types/scopes.ts b/packages/grafana-data/src/types/scopes.ts index 2f85a19237d..b90a0e9feb2 100644 --- a/packages/grafana-data/src/types/scopes.ts +++ b/packages/grafana-data/src/types/scopes.ts @@ -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; diff --git a/public/app/features/commandPalette/ScopesRow.tsx b/public/app/features/commandPalette/ScopesRow.tsx index 0f2d9521517..30d5bef8487 100644 --- a/public/app/features/commandPalette/ScopesRow.tsx +++ b/public/app/features/commandPalette/ScopesRow.tsx @@ -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) { Scopes: - {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 ( { - toggleNode(scope); + deselectScope(scope.scopeNodeId || scope.scopeId); }} /> ); diff --git a/public/app/features/commandPalette/actions/recentScopesActions.ts b/public/app/features/commandPalette/actions/recentScopesActions.ts index 387c1bb082d..cd5844cb88e 100644 --- a/public/app/features/commandPalette/actions/recentScopesActions.ts +++ b/public/app/features/commandPalette/actions/recentScopesActions.ts @@ -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)); }, }; }); diff --git a/public/app/features/commandPalette/actions/useActions.tsx b/public/app/features/commandPalette/actions/useActions.tsx index 7583aa39388..8e6f2aa773b 100644 --- a/public/app/features/commandPalette/actions/useActions.tsx +++ b/public/app/features/commandPalette/actions/useActions.tsx @@ -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 ? ( - + isDirty || selectedScopes?.length ? ( + ) : null, }; } -function mapScopeNodesToActions( - nodes: NodesMap, - selectedScopes: TreeScope[], - toggleNodeSelect: (node: ToggleNode) => void +function useGlobalScopesSearch( + searchQuery: string, + searchAllNodes: (search: string, limit: number) => Promise, + parentId?: string | null ) { + const [nodes, setNodes] = useState(undefined); + const searchQueryRef = useRef(); + + // 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; } diff --git a/public/app/features/scopes/ScopesApiClient.ts b/public/app/features/scopes/ScopesApiClient.ts index 5020df6b82a..9f90e0c62fe 100644 --- a/public/app/features/scopes/ScopesApiClient.ts +++ b/public/app/features/scopes/ScopesApiClient.ts @@ -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>(); - - async fetchScope(name: string): Promise { - if (this.scopesCache.has(name)) { - return this.scopesCache.get(name)!; + async fetchScope(name: string): Promise { + try { + return await getBackendSrv().get(apiUrl + `/scopes/${name}`); + } catch (err) { + // TODO: maybe some better error handling + console.error(err); + return undefined; } - - const response = new Promise(async (resolve) => { - const basicScope = getEmptyScopeObject(name); - - try { - const serverScope = await getBackendSrv().get(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 { - const scopes = await Promise.all(treeScopes.map(({ scopeName }) => this.fetchScope(scopeName))); - - return scopes.map((scope, idx) => { - return { - scope, - path: treeScopes[idx].path, - }; - }); + async fetchMultipleScopes(scopesIds: string[]): Promise { + 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} 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 { + async fetchNodes(options: { parent?: string; query?: string; limit?: number }): Promise { + 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((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 []; } } diff --git a/public/app/features/scopes/ScopesService.ts b/public/app/features/scopes/ScopesService.ts index 3a6c0889e58..e586c1666de 100644 --- a/public/app/features/scopes/ScopesService.ts +++ b/public/app/features/scopes/ScopesService.ts @@ -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( diff --git a/public/app/features/scopes/selector/RecentScopes.tsx b/public/app/features/scopes/selector/RecentScopes.tsx index 5c910116a2f..e8bc2633398 100644 --- a/public/app/features/scopes/selector/RecentScopes.tsx +++ b/public/app/features/scopes/selector/RecentScopes.tsx @@ -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) => ( ))} diff --git a/public/app/features/scopes/selector/ScopesInput.tsx b/public/app/features/scopes/selector/ScopesInput.tsx index a9042c849aa..3f23ead6224 100644 --- a/public/app/features/scopes/selector/ScopesInput.tsx +++ b/public/app/features/scopes/selector/ScopesInput.tsx @@ -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>((acc, { scope, path }) => { - let currentLevel = nodes; + const tooltipContent = + appliedScopes.length > 0 ? : <>; - const titles = path.reduce((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]) => ( -

- {path ? `${path} > ${scopesTitles}` : scopesTitles} -

- ))} - - ); - }, [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 ? ( ), - [disabled, loading, onInputClick, onRemoveAllClick, scopes, scopesTitles, t] + [disabled, loading, onInputClick, onRemoveAllClick, appliedScopes, scopesTitles, t] ); return ( - + {input} ); } +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 ( + <> +

+ {(nicePath && nicePath.length > 0 ? nicePath.join(' > ') + ' > ' : '') + scopeNames.join(', ')} +

+ + ); +} + const getStyles = (theme: GrafanaTheme2) => { return { scopePath: css({ diff --git a/public/app/features/scopes/selector/ScopesSelector.tsx b/public/app/features/scopes/selector/ScopesSelector.tsx index db14e2c61d0..b8d6f18173a 100644 --- a/public/app/features/scopes/selector/ScopesSelector.tsx +++ b/public/app/features/scopes/selector/ScopesSelector.tsx @@ -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 = () => { { @@ -76,21 +94,22 @@ export const ScopesSelector = () => {
- {loading ? ( + {loading || !tree ? ( ) : ( <> { - 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(); }} /> diff --git a/public/app/features/scopes/selector/ScopesSelectorService.test.ts b/public/app/features/scopes/selector/ScopesSelectorService.test.ts index a511ec86831..bb9ab1c40c4 100644 --- a/public/app/features/scopes/selector/ScopesSelectorService.test.ts +++ b/public/app/features/scopes/selector/ScopesSelectorService.test.ts @@ -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 = {}; + 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; @@ -53,214 +62,195 @@ describe('ScopesSelectorService', () => { fetchDashboards: jest.fn(), } as unknown as jest.Mocked; - 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([]); }); }); }); diff --git a/public/app/features/scopes/selector/ScopesSelectorService.ts b/public/app/features/scopes/selector/ScopesSelectorService.ts index aae713b5f02..fdc4c3d230a 100644 --- a/public/app/features/scopes/selector/ScopesSelectorService.ts +++ b/public/app/features/scopes/selector/ScopesSelectorService.ts @@ -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 { 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((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 { - 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 { - 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((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>((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; } diff --git a/public/app/features/scopes/selector/ScopesTree.tsx b/public/app/features/scopes/selector/ScopesTree.tsx index 0037052a90c..71c7a879d63 100644 --- a/public/app/features/scopes/selector/ScopesTree.tsx +++ b/public/app/features/scopes/selector/ScopesTree.tsx @@ -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 = 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 ( <> - - {nodePath.length === 1 && - nodePath[0] === '' && + + {tree.scopeNodeId === '' && !anyChildExpanded && recentScopes && recentScopes.length > 0 && onRecentScopesSelect && - !node.query && } + !tree.query && } - - + {nodeLoading ? ( + + ) : ( + <> + - + - - + + + )} ); } + +const getStyles = (theme: GrafanaTheme2) => { + return { + loader: css({ + margin: theme.spacing(0.5, 0), + }), + }; +}; diff --git a/public/app/features/scopes/selector/ScopesTreeHeadline.tsx b/public/app/features/scopes/selector/ScopesTreeHeadline.tsx index 873a860cd2d..755fadf64b5 100644 --- a/public/app/features/scopes/selector/ScopesTreeHeadline.tsx +++ b/public/app/features/scopes/selector/ScopesTreeHeadline.tsx @@ -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; } diff --git a/public/app/features/scopes/selector/ScopesTreeItem.tsx b/public/app/features/scopes/selector/ScopesTreeItem.tsx index 3cc02ad207d..15f3277e096 100644 --- a/public/app/features/scopes/selector/ScopesTreeItem.tsx +++ b/public/app/features/scopes/selector/ScopesTreeItem.tsx @@ -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; - 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 = ( -
- {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 ( +
+
+ {isSelectable && !treeNode.expanded ? ( + disableMultiSelect ? ( + { + selected ? deselectScope(treeNode.scopeNodeId) : selectScope(treeNode.scopeNodeId); + }} + /> + ) : ( + { + selected ? deselectScope(treeNode.scopeNodeId) : selectScope(treeNode.scopeNodeId); + }} + /> + ) + ) : null} - const radioName = childNodePath.join('.'); - - return ( -
{ + onNodeUpdate(treeNode.scopeNodeId, !treeNode.expanded, treeNode.query); + }} > -
- {childNode.selectable && !childNode.expanded ? ( - node.disableMultiSelect ? ( - { - onNodeSelectToggle({ path: childNodePath }); - }} - /> - ) : ( - { - onNodeSelectToggle({ path: childNodePath }); - }} - /> - ) - ) : null} + - {childNode.expandable ? ( - + ) : ( + {scopeNode.spec.title} + )} +
- {childNode.title} - - ) : ( - {childNode.title} - )} -
- -
- {childNode.expanded && ( - - )} -
-
- ); - })} +
+ {treeNode.expanded && ( + + )} +
); - - if (lastExpandedNode) { - return ( - - {children} - - ); - } - - return children; } const getStyles = (theme: GrafanaTheme2) => { diff --git a/public/app/features/scopes/selector/ScopesTreeItemList.tsx b/public/app/features/scopes/selector/ScopesTreeItemList.tsx new file mode 100644 index 00000000000..8f663b0db0a --- /dev/null +++ b/public/app/features/scopes/selector/ScopesTreeItemList.tsx @@ -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 = ( +
+ {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 ( + + ); + })} +
+ ); + + if (lastExpandedNode) { + return ( + + {children} + + ); + } + + return children; +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + expandedContainer: css({ + display: 'flex', + flexDirection: 'column', + maxHeight: '100%', + }), + }; +}; diff --git a/public/app/features/scopes/selector/ScopesTreeLoading.tsx b/public/app/features/scopes/selector/ScopesTreeLoading.tsx deleted file mode 100644 index 3f24890d4d7..00000000000 --- a/public/app/features/scopes/selector/ScopesTreeLoading.tsx +++ /dev/null @@ -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 ; - } - - return children; -} - -const getStyles = (theme: GrafanaTheme2) => { - return { - loader: css({ - margin: theme.spacing(0.5, 0), - }), - }; -}; diff --git a/public/app/features/scopes/selector/ScopesTreeSearch.tsx b/public/app/features/scopes/selector/ScopesTreeSearch.tsx index b3aa7c5504f..972770ff910 100644 --- a/public/app/features/scopes/selector/ScopesTreeSearch.tsx +++ b/public/app/features/scopes/selector/ScopesTreeSearch.tsx @@ -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, diff --git a/public/app/features/scopes/selector/scopesTreeUtils.test.ts b/public/app/features/scopes/selector/scopesTreeUtils.test.ts new file mode 100644 index 00000000000..9c88c51b6d5 --- /dev/null +++ b/public/app/features/scopes/selector/scopesTreeUtils.test.ts @@ -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(); + }); + }); +}); diff --git a/public/app/features/scopes/selector/scopesTreeUtils.ts b/public/app/features/scopes/selector/scopesTreeUtils.ts new file mode 100644 index 00000000000..2acb3bc86a6 --- /dev/null +++ b/public/app/features/scopes/selector/scopesTreeUtils.ts @@ -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; +} diff --git a/public/app/features/scopes/selector/types.ts b/public/app/features/scopes/selector/types.ts index 11d6e3c7693..d0b109ab08c 100644 --- a/public/app/features/scopes/selector/types.ts +++ b/public/app/features/scopes/selector/types.ts @@ -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; +export type NodesMap = Record; +export type ScopesMap = Record; 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; } - -// 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; diff --git a/public/app/features/scopes/tests/tree.test.ts b/public/app/features/scopes/tests/tree.test.ts index c630eb9bd70..30d698d6410 100644 --- a/public/app/features/scopes/tests/tree.test.ts +++ b/public/app/features/scopes/tests/tree.test.ts @@ -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, []); - }); }); diff --git a/public/app/features/scopes/tests/utils/assertions.ts b/public/app/features/scopes/tests/utils/assertions.ts index f9425179988..cab6c527c06 100644 --- a/public/app/features/scopes/tests/utils/assertions.ts +++ b/public/app/features/scopes/tests/utils/assertions.ts @@ -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); diff --git a/public/app/features/scopes/tests/utils/mocks.ts b/public/app/features/scopes/tests/utils/mocks.ts index f61e251a8bb..2482e9c2053 100644 --- a/public/app/features/scopes/tests/utils/mocks.ts +++ b/public/app/features/scopes/tests/utils/mocks.ts @@ -169,18 +169,17 @@ export const mocksScopeDashboardBindings: ScopeDashboardBinding[] = [ ), ] as const; -export const mocksNodes: Array = [ +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 = [ disableMultiSelect: true, linkType: 'scope', linkId: 'cloud', + parentName: '', }, }, { - parent: 'applications', metadata: { name: 'applications-grafana' }, spec: { nodeType: 'leaf', @@ -200,10 +199,10 @@ export const mocksNodes: Array = [ 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 = [ 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 = [ 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 = [ 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 = [ 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 = [ 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 = [ 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 = [ 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 = [ 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 = [ 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 = [ 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 = [ 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 = [ 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 = [ 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 = [ 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()) ), }; } diff --git a/public/app/features/scopes/tests/utils/selectors.ts b/public/app/features/scopes/tests/utils/selectors.ts index 79d0b7ca3d2..85d86968ba5 100644 --- a/public/app/features/scopes/tests/utils/selectors.ts +++ b/public/app/features/scopes/tests/utils/selectors.ts @@ -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(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(selectors.tree.select('applications-grafana', 'result')); + screen.queryByTestId(selectors.tree.select('applications-grafana')); export const getResultApplicationsGrafanaSelect = () => - screen.getByTestId(selectors.tree.select('applications-grafana', 'result')); + screen.getByTestId(selectors.tree.select('applications-grafana')); export const queryPersistedApplicationsGrafanaSelect = () => - screen.queryByTestId(selectors.tree.select('applications-grafana', 'persisted')); + screen.queryByTestId(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(selectors.tree.select('applications-mimir', 'result')); + screen.getByTestId(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(selectors.tree.radio('cloud-dev', 'result')); -export const getResultCloudOpsRadio = () => - screen.getByTestId(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(selectors.tree.radio('cloud-dev')); +export const getResultCloudOpsRadio = () => screen.getByTestId(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);