mirror of https://github.com/grafana/grafana.git
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:
parent
32434810e1
commit
972e2f31e5
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 })));
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
await selectRecentScope('Grafana, Mimir');
|
||||
|
||||
expectScopesSelectorValue('Grafana, Mimir');
|
||||
expectScopesSelectorValue('Grafana + Mimir');
|
||||
});
|
||||
|
||||
it('recent scopes should not be visible when the first scope is selected', async () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue