ScopesInput: Display parent and use + as separator (#107698)

* Display group and use + as separator

* Set/get parent node from URL

* Add hook useScopeNode

* Add loading state and Skeleton

* Add preload and actually commit hook

* Add preload

* Add feature flag

* Update test

* Fix test

* Fix tree test
This commit is contained in:
Tobias Skarhed 2025-07-31 16:32:41 +02:00 committed by GitHub
parent 32434810e1
commit 972e2f31e5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 172 additions and 25 deletions

View File

@ -457,6 +457,11 @@ export interface FeatureToggles {
*/
scopeApi?: boolean;
/**
* Use the single node endpoint for the scope api. This is used to fetch the scope parent node.
* @default false
*/
useScopeSingleNodeEndpoint?: boolean;
/**
* In-development feature that will allow injection of labels into prometheus queries.
* @default true
*/

View File

@ -769,6 +769,16 @@ var (
HideFromAdminPage: true,
Expression: "false",
},
{
Name: "useScopeSingleNodeEndpoint",
Description: "Use the single node endpoint for the scope api. This is used to fetch the scope parent node.",
Stage: FeatureStageExperimental,
Owner: grafanaOperatorExperienceSquad,
Expression: "false",
FrontendOnly: true,
HideFromDocs: true,
HideFromAdminPage: true,
},
{
Name: "promQLScope",
Description: "In-development feature that will allow injection of labels into prometheus queries.",

View File

@ -101,6 +101,7 @@ secretsManagementAppPlatform,experimental,@grafana/grafana-operator-experience-s
alertingSaveStatePeriodic,privatePreview,@grafana/alerting-squad,false,false,false
alertingSaveStateCompressed,preview,@grafana/alerting-squad,false,false,false
scopeApi,experimental,@grafana/grafana-app-platform-squad,false,false,false
useScopeSingleNodeEndpoint,experimental,@grafana/grafana-operator-experience-squad,false,false,true
promQLScope,GA,@grafana/oss-big-tent,false,false,false
logQLScope,privatePreview,@grafana/observability-logs,false,false,false
sqlExpressions,privatePreview,@grafana/grafana-datasources-core-services,false,false,false

1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
101 alertingSaveStatePeriodic privatePreview @grafana/alerting-squad false false false
102 alertingSaveStateCompressed preview @grafana/alerting-squad false false false
103 scopeApi experimental @grafana/grafana-app-platform-squad false false false
104 useScopeSingleNodeEndpoint experimental @grafana/grafana-operator-experience-squad false false true
105 promQLScope GA @grafana/oss-big-tent false false false
106 logQLScope privatePreview @grafana/observability-logs false false false
107 sqlExpressions privatePreview @grafana/grafana-datasources-core-services false false false

View File

@ -415,6 +415,10 @@ const (
// In-development feature flag for the scope api using the app platform.
FlagScopeApi = "scopeApi"
// FlagUseScopeSingleNodeEndpoint
// Use the single node endpoint for the scope api. This is used to fetch the scope parent node.
FlagUseScopeSingleNodeEndpoint = "useScopeSingleNodeEndpoint"
// FlagPromQLScope
// In-development feature that will allow injection of labels into prometheus queries.
FlagPromQLScope = "promQLScope"

View File

@ -3242,6 +3242,22 @@
"hideFromDocs": true
}
},
{
"metadata": {
"name": "useScopeSingleNodeEndpoint",
"resourceVersion": "1753960766702",
"creationTimestamp": "2025-07-31T11:19:26Z"
},
"spec": {
"description": "Use the single node endpoint for the scope api. This is used to fetch the scope parent node.",
"stage": "experimental",
"codeowner": "@grafana/grafana-operator-experience-squad",
"frontend": true,
"hideFromAdminPage": true,
"hideFromDocs": true,
"expression": "false"
}
},
{
"metadata": {
"name": "useScopesNavigationEndpoint",

View File

@ -1,5 +1,5 @@
import { Scope, ScopeDashboardBinding, ScopeNode } from '@grafana/data';
import { getBackendSrv } from '@grafana/runtime';
import { getBackendSrv, config } from '@grafana/runtime';
import { getAPINamespace } from '../../api/utils';
@ -84,4 +84,16 @@ export class ScopesApiClient {
return [];
}
};
public fetchScopeNode = async (scopeNodeId: string): Promise<ScopeNode | undefined> => {
if (!config.featureToggles.useScopeSingleNodeEndpoint) {
return Promise.resolve(undefined);
}
try {
const response = await getBackendSrv().get<ScopeNode>(apiUrl + `/scopenodes/${scopeNodeId}`);
return response;
} catch (err) {
return undefined;
}
};
}

View File

@ -70,7 +70,16 @@ export class ScopesService implements ScopesContextValue {
// Init from the URL when we first load
const queryParams = new URLSearchParams(locationService.getLocation().search);
this.changeScopes(queryParams.getAll('scopes'));
const parentNodeId = queryParams.get('scope_parent');
this.changeScopes(queryParams.getAll('scopes'), parentNodeId ?? undefined);
// Pre-load parent node, to prevent UI flickering
if (parentNodeId) {
this.selectorService.getScopeNode(parentNodeId).catch((error) => {
console.error('Failed to pre-load parent node', error);
});
}
// Update scopes state based on URL.
this.subscriptions.push(
@ -80,13 +89,16 @@ export class ScopesService implements ScopesContextValue {
return;
}
const queryParams = new URLSearchParams(location.search);
// If we have a parent node in the URL, fetch and expand it
const parentNode = queryParams.get('scope_parent');
const scopes = queryParams.getAll('scopes');
//const scopesFromState = this.state.value.map((scope) => scope.metadata.name);
if (scopes.length) {
// We only update scopes but never delete them. This is to keep the scopes in memory if user navigates to
// page that does not use scopes (like from dashboard to dashboard list back to dashboard). If user
// changes the URL directly, it would trigger a reload so scopes would still be reset.
this.changeScopes(scopes);
this.changeScopes(scopes, parentNode ?? undefined);
}
})
);
@ -94,6 +106,12 @@ export class ScopesService implements ScopesContextValue {
// Update the URL based on change in the scopes state
this.subscriptions.push(
selectorService.subscribeToState((state, prev) => {
const oldParentNode = prev.appliedScopes[0]?.parentNodeId;
const newParentNode = state.appliedScopes[0]?.parentNodeId;
if (oldParentNode !== newParentNode && newParentNode) {
this.locationService.partial({ scope_parent: newParentNode }, true);
}
const oldScopeNames = prev.appliedScopes.map((scope) => scope.scopeId);
const newScopeNames = state.appliedScopes.map((scope) => scope.scopeId);
if (!isEqual(oldScopeNames, newScopeNames)) {
@ -124,7 +142,8 @@ export class ScopesService implements ScopesContextValue {
return this._stateObservable;
}
public changeScopes = (scopeNames: string[]) => this.selectorService.changeScopes(scopeNames);
public changeScopes = (scopeNames: string[], parentNodeId?: string) =>
this.selectorService.changeScopes(scopeNames, parentNodeId);
public setReadOnly = (readOnly: boolean) => {
if (this.state.readOnly !== readOnly) {

View File

@ -1,5 +1,6 @@
import { css } from '@emotion/css';
import { useEffect, useMemo, useState } from 'react';
import Skeleton from 'react-loading-skeleton';
import { GrafanaTheme2 } from '@grafana/data';
import { t } from '@grafana/i18n';
@ -7,6 +8,7 @@ import { IconButton, Input, Tooltip, useStyles2 } from '@grafana/ui';
import { getPathOfNode } from './scopesTreeUtils';
import { NodesMap, ScopesMap, SelectedScope } from './types';
import { useScopeNode } from './useScopeNode';
export interface ScopesInputProps {
nodes: NodesMap;
@ -32,6 +34,10 @@ export function ScopesInput({
}: ScopesInputProps) {
const [tooltipVisible, setTooltipVisible] = useState(false);
const parentNodeId = appliedScopes[0]?.parentNodeId;
const { node: parentNode, isLoading: parentNodeLoading } = useScopeNode(parentNodeId);
const parentNodeTitle = parentNode?.spec.title;
useEffect(() => {
setTooltipVisible(false);
}, [appliedScopes]);
@ -47,10 +53,20 @@ export function ScopesInput({
// If we are still loading the scope data just show the id
scopes[s.scopeId]?.spec.title || s.scopeId
)
.join(', '),
.join(' + '),
[appliedScopes, scopes]
);
const parentNodePrefix = useMemo(
() =>
parentNodeLoading ? (
<Skeleton width={30} height={14} />
) : parentNodeTitle ? (
<span>{parentNodeTitle}:</span>
) : undefined,
[parentNodeLoading, parentNodeTitle]
);
const input = useMemo(
() => (
<Input
@ -61,6 +77,7 @@ export function ScopesInput({
value={scopesTitles}
aria-label={t('scopes.selector.input.placeholder', 'Select scopes...')}
data-testid="scopes-selector-input"
prefix={parentNodePrefix}
suffix={
appliedScopes.length > 0 && !disabled ? (
<IconButton
@ -80,7 +97,7 @@ export function ScopesInput({
}}
/>
),
[disabled, loading, onInputClick, onRemoveAllClick, appliedScopes, scopesTitles]
[disabled, loading, onInputClick, onRemoveAllClick, appliedScopes, scopesTitles, parentNodePrefix]
);
return (
@ -90,6 +107,21 @@ export function ScopesInput({
);
}
const getScopesPath = (appliedScopes: SelectedScope[], nodes: NodesMap) => {
let nicePath: string[] | undefined;
if (appliedScopes.length > 0 && 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);
}
return nicePath;
};
export interface ScopesTooltipProps {
nodes: NodesMap;
scopes: ScopesMap;
@ -99,16 +131,7 @@ export interface ScopesTooltipProps {
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 nicePath = getScopesPath(appliedScopes, nodes);
const scopeNames = appliedScopes.map((s) => {
if (s.scopeNodeId) {

View File

@ -71,6 +71,24 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
});
}
// Loads a node from the API and adds it to the nodes cache
public getScopeNode = async (scopeNodeId: string) => {
if (this.state.nodes[scopeNodeId]) {
return this.state.nodes[scopeNodeId];
}
try {
const node = await this.apiClient.fetchScopeNode(scopeNodeId);
if (node) {
this.updateState({ nodes: { ...this.state.nodes, [node.metadata.name]: node } });
}
return node;
} catch (error) {
console.error('Failed to load node', error);
return undefined;
}
};
private expandOrFilterNode = async (scopeNodeId: string, query?: string) => {
const path = getPathOfNode(scopeNodeId, this.state.nodes);
@ -160,11 +178,15 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
}
});
// 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
// TODO: if we do global search we may not have a parent 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 };
const selectedScope = {
scopeId: scopeNode.spec.linkId,
scopeNodeId: scopeNode.metadata.name,
parentNodeId: parentNode?.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
@ -214,8 +236,8 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
return this.collapseNode(scopeNodeId);
};
changeScopes = (scopeNames: string[]) => {
return this.applyScopes(scopeNames.map((id) => ({ scopeId: id })));
changeScopes = (scopeNames: string[], parentNodeId?: string) => {
return this.applyScopes(scopeNames.map((id) => ({ scopeId: id, parentNodeId: parentNodeId })));
};
/**

View File

@ -6,6 +6,8 @@ export type ScopesMap = Record<string, Scope>;
export interface SelectedScope {
scopeId: string;
scopeNodeId?: string;
// Used to display title next to selected scope
parentNodeId?: string;
}
export interface TreeNode {

View File

@ -0,0 +1,33 @@
import { useEffect, useState } from 'react';
import { ScopeNode } from '@grafana/data';
import { useScopesServices } from '../ScopesContextProvider';
// Light wrapper around the scopesSelectorService.getScopeNode to make it easier to use in the UI.
export function useScopeNode(scopeNodeId?: string) {
const [node, setNode] = useState<ScopeNode | undefined>(undefined);
const [isLoading, setIsLoading] = useState(false);
const scopesSelectorService = useScopesServices()?.scopesSelectorService;
useEffect(() => {
const loadNode = async () => {
if (!scopeNodeId || !scopesSelectorService) {
setNode(undefined);
return;
}
setIsLoading(true);
try {
const node = await scopesSelectorService.getScopeNode(scopeNodeId);
setNode(node);
} catch (error) {
console.error('Failed to load node', error);
} finally {
setIsLoading(false);
}
};
loadNode();
}, [scopeNodeId, scopesSelectorService]);
return { node, isLoading };
}

View File

@ -115,7 +115,7 @@ describe('Selector', () => {
expectRecentScopeNotPresent('Mimir');
await selectRecentScope('Grafana, Mimir');
expectScopesSelectorValue('Grafana, Mimir');
expectScopesSelectorValue('Grafana + Mimir');
});
it('recent scopes should not be visible when the first scope is selected', async () => {

View File

@ -103,7 +103,7 @@ describe('Tree', () => {
await selectResultApplicationsMimir();
await selectResultApplicationsCloud();
await applyScopes();
expectScopesSelectorValue('Grafana, Mimir, Cloud');
expectScopesSelectorValue('Grafana + Mimir + Cloud');
});
it('Can select a node from an inner level', async () => {
@ -225,7 +225,7 @@ describe('Tree', () => {
await selectResultApplicationsGrafana();
await applyScopes();
expectScopesSelectorValue('Mimir, Grafana');
expectScopesSelectorValue('Mimir + Grafana');
});
it('Deselects a persisted scope', async () => {
@ -237,7 +237,7 @@ describe('Tree', () => {
await selectResultApplicationsGrafana();
await applyScopes();
expectScopesSelectorValue('Mimir, Grafana');
expectScopesSelectorValue('Mimir + Grafana');
await openSelector();
await selectPersistedApplicationsMimir();