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
13 changed files with 172 additions and 25 deletions
@@ -457,6 +457,11 @@ export interface FeatureToggles {
*/ */
scopeApi?: boolean; 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. * In-development feature that will allow injection of labels into prometheus queries.
* @default true * @default true
*/ */
+10
View File
@@ -769,6 +769,16 @@ var (
HideFromAdminPage: true, HideFromAdminPage: true,
Expression: "false", 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", Name: "promQLScope",
Description: "In-development feature that will allow injection of labels into prometheus queries.", Description: "In-development feature that will allow injection of labels into prometheus queries.",
+1
View File
@@ -101,6 +101,7 @@ secretsManagementAppPlatform,experimental,@grafana/grafana-operator-experience-s
alertingSaveStatePeriodic,privatePreview,@grafana/alerting-squad,false,false,false alertingSaveStatePeriodic,privatePreview,@grafana/alerting-squad,false,false,false
alertingSaveStateCompressed,preview,@grafana/alerting-squad,false,false,false alertingSaveStateCompressed,preview,@grafana/alerting-squad,false,false,false
scopeApi,experimental,@grafana/grafana-app-platform-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 promQLScope,GA,@grafana/oss-big-tent,false,false,false
logQLScope,privatePreview,@grafana/observability-logs,false,false,false logQLScope,privatePreview,@grafana/observability-logs,false,false,false
sqlExpressions,privatePreview,@grafana/grafana-datasources-core-services,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
+4
View File
@@ -415,6 +415,10 @@ const (
// In-development feature flag for the scope api using the app platform. // In-development feature flag for the scope api using the app platform.
FlagScopeApi = "scopeApi" FlagScopeApi = "scopeApi"
// FlagUseScopeSingleNodeEndpoint
// Use the single node endpoint for the scope api. This is used to fetch the scope parent node.
FlagUseScopeSingleNodeEndpoint = "useScopeSingleNodeEndpoint"
// FlagPromQLScope // FlagPromQLScope
// In-development feature that will allow injection of labels into prometheus queries. // In-development feature that will allow injection of labels into prometheus queries.
FlagPromQLScope = "promQLScope" FlagPromQLScope = "promQLScope"
+16
View File
@@ -3242,6 +3242,22 @@
"hideFromDocs": true "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": { "metadata": {
"name": "useScopesNavigationEndpoint", "name": "useScopesNavigationEndpoint",
+13 -1
View File
@@ -1,5 +1,5 @@
import { Scope, ScopeDashboardBinding, ScopeNode } from '@grafana/data'; import { Scope, ScopeDashboardBinding, ScopeNode } from '@grafana/data';
import { getBackendSrv } from '@grafana/runtime'; import { getBackendSrv, config } from '@grafana/runtime';
import { getAPINamespace } from '../../api/utils'; import { getAPINamespace } from '../../api/utils';
@@ -84,4 +84,16 @@ export class ScopesApiClient {
return []; 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;
}
};
} }
+23 -4
View File
@@ -70,7 +70,16 @@ export class ScopesService implements ScopesContextValue {
// Init from the URL when we first load // Init from the URL when we first load
const queryParams = new URLSearchParams(locationService.getLocation().search); 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. // Update scopes state based on URL.
this.subscriptions.push( this.subscriptions.push(
@@ -80,13 +89,16 @@ export class ScopesService implements ScopesContextValue {
return; return;
} }
const queryParams = new URLSearchParams(location.search); 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 scopes = queryParams.getAll('scopes');
//const scopesFromState = this.state.value.map((scope) => scope.metadata.name);
if (scopes.length) { if (scopes.length) {
// We only update scopes but never delete them. This is to keep the scopes in memory if user navigates to // 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 // 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. // 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 // Update the URL based on change in the scopes state
this.subscriptions.push( this.subscriptions.push(
selectorService.subscribeToState((state, prev) => { 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 oldScopeNames = prev.appliedScopes.map((scope) => scope.scopeId);
const newScopeNames = state.appliedScopes.map((scope) => scope.scopeId); const newScopeNames = state.appliedScopes.map((scope) => scope.scopeId);
if (!isEqual(oldScopeNames, newScopeNames)) { if (!isEqual(oldScopeNames, newScopeNames)) {
@@ -124,7 +142,8 @@ export class ScopesService implements ScopesContextValue {
return this._stateObservable; 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) => { public setReadOnly = (readOnly: boolean) => {
if (this.state.readOnly !== readOnly) { if (this.state.readOnly !== readOnly) {
@@ -1,5 +1,6 @@
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import Skeleton from 'react-loading-skeleton';
import { GrafanaTheme2 } from '@grafana/data'; import { GrafanaTheme2 } from '@grafana/data';
import { t } from '@grafana/i18n'; import { t } from '@grafana/i18n';
@@ -7,6 +8,7 @@ import { IconButton, Input, Tooltip, useStyles2 } from '@grafana/ui';
import { getPathOfNode } from './scopesTreeUtils'; import { getPathOfNode } from './scopesTreeUtils';
import { NodesMap, ScopesMap, SelectedScope } from './types'; import { NodesMap, ScopesMap, SelectedScope } from './types';
import { useScopeNode } from './useScopeNode';
export interface ScopesInputProps { export interface ScopesInputProps {
nodes: NodesMap; nodes: NodesMap;
@@ -32,6 +34,10 @@ export function ScopesInput({
}: ScopesInputProps) { }: ScopesInputProps) {
const [tooltipVisible, setTooltipVisible] = useState(false); const [tooltipVisible, setTooltipVisible] = useState(false);
const parentNodeId = appliedScopes[0]?.parentNodeId;
const { node: parentNode, isLoading: parentNodeLoading } = useScopeNode(parentNodeId);
const parentNodeTitle = parentNode?.spec.title;
useEffect(() => { useEffect(() => {
setTooltipVisible(false); setTooltipVisible(false);
}, [appliedScopes]); }, [appliedScopes]);
@@ -47,10 +53,20 @@ export function ScopesInput({
// If we are still loading the scope data just show the id // If we are still loading the scope data just show the id
scopes[s.scopeId]?.spec.title || s.scopeId scopes[s.scopeId]?.spec.title || s.scopeId
) )
.join(', '), .join(' + '),
[appliedScopes, scopes] [appliedScopes, scopes]
); );
const parentNodePrefix = useMemo(
() =>
parentNodeLoading ? (
<Skeleton width={30} height={14} />
) : parentNodeTitle ? (
<span>{parentNodeTitle}:</span>
) : undefined,
[parentNodeLoading, parentNodeTitle]
);
const input = useMemo( const input = useMemo(
() => ( () => (
<Input <Input
@@ -61,6 +77,7 @@ export function ScopesInput({
value={scopesTitles} value={scopesTitles}
aria-label={t('scopes.selector.input.placeholder', 'Select scopes...')} aria-label={t('scopes.selector.input.placeholder', 'Select scopes...')}
data-testid="scopes-selector-input" data-testid="scopes-selector-input"
prefix={parentNodePrefix}
suffix={ suffix={
appliedScopes.length > 0 && !disabled ? ( appliedScopes.length > 0 && !disabled ? (
<IconButton <IconButton
@@ -80,7 +97,7 @@ export function ScopesInput({
}} }}
/> />
), ),
[disabled, loading, onInputClick, onRemoveAllClick, appliedScopes, scopesTitles] [disabled, loading, onInputClick, onRemoveAllClick, appliedScopes, scopesTitles, parentNodePrefix]
); );
return ( 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 { export interface ScopesTooltipProps {
nodes: NodesMap; nodes: NodesMap;
scopes: ScopesMap; scopes: ScopesMap;
@@ -99,16 +131,7 @@ export interface ScopesTooltipProps {
function ScopesTooltip({ nodes, scopes, appliedScopes }: ScopesTooltipProps) { function ScopesTooltip({ nodes, scopes, appliedScopes }: ScopesTooltipProps) {
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
let nicePath: string[] | undefined; const nicePath = getScopesPath(appliedScopes, nodes);
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) => { const scopeNames = appliedScopes.map((s) => {
if (s.scopeNodeId) { if (s.scopeNodeId) {
@@ -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) => { private expandOrFilterNode = async (scopeNodeId: string, query?: string) => {
const path = getPathOfNode(scopeNodeId, this.state.nodes); 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 // would allow us to load scopeNode by ID right now so this can be undefined which means we skip the
// disableMultiSelect check. // disableMultiSelect check.
const parentNode = this.state.nodes[scopeNode.spec.parentName!]; 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 // 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 // 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); return this.collapseNode(scopeNodeId);
}; };
changeScopes = (scopeNames: string[]) => { changeScopes = (scopeNames: string[], parentNodeId?: string) => {
return this.applyScopes(scopeNames.map((id) => ({ scopeId: id }))); return this.applyScopes(scopeNames.map((id) => ({ scopeId: id, parentNodeId: parentNodeId })));
}; };
/** /**
@@ -6,6 +6,8 @@ export type ScopesMap = Record<string, Scope>;
export interface SelectedScope { export interface SelectedScope {
scopeId: string; scopeId: string;
scopeNodeId?: string; scopeNodeId?: string;
// Used to display title next to selected scope
parentNodeId?: string;
} }
export interface TreeNode { export interface TreeNode {
@@ -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 };
}
@@ -115,7 +115,7 @@ describe('Selector', () => {
expectRecentScopeNotPresent('Mimir'); expectRecentScopeNotPresent('Mimir');
await selectRecentScope('Grafana, 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 () => { it('recent scopes should not be visible when the first scope is selected', async () => {
@@ -103,7 +103,7 @@ describe('Tree', () => {
await selectResultApplicationsMimir(); await selectResultApplicationsMimir();
await selectResultApplicationsCloud(); await selectResultApplicationsCloud();
await applyScopes(); await applyScopes();
expectScopesSelectorValue('Grafana, Mimir, Cloud'); expectScopesSelectorValue('Grafana + Mimir + Cloud');
}); });
it('Can select a node from an inner level', async () => { it('Can select a node from an inner level', async () => {
@@ -225,7 +225,7 @@ describe('Tree', () => {
await selectResultApplicationsGrafana(); await selectResultApplicationsGrafana();
await applyScopes(); await applyScopes();
expectScopesSelectorValue('Mimir, Grafana'); expectScopesSelectorValue('Mimir + Grafana');
}); });
it('Deselects a persisted scope', async () => { it('Deselects a persisted scope', async () => {
@@ -237,7 +237,7 @@ describe('Tree', () => {
await selectResultApplicationsGrafana(); await selectResultApplicationsGrafana();
await applyScopes(); await applyScopes();
expectScopesSelectorValue('Mimir, Grafana'); expectScopesSelectorValue('Mimir + Grafana');
await openSelector(); await openSelector();
await selectPersistedApplicationsMimir(); await selectPersistedApplicationsMimir();