Scopes: Save parent node with recent scope (#108795)

* Read and write recent scopes

* Store entire ScopeNode, load from localSotrage

* Load nodes form localStorage on init

* Add runtime type validation

* Add tests

* Remove unused import

* Add parentNode name to selector integration test

* Deep clone

* Schema validation with zod

* Add comment

* Use zod for schema validation of scope object

* Update type validation of recent scope nodes

* Add comment

* Add expect ts error

* Fix nit
This commit is contained in:
Tobias Skarhed
2025-08-05 12:09:58 +00:00
committed by GitHub
parent d874bc08b7
commit 3048fc6205
8 changed files with 327 additions and 43 deletions
@@ -20,6 +20,8 @@ export function getRecentScopesActions(): CommandPaletteAction[] {
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'),
// Only show the parent of the first scope for now
subtitle: recentScope[0]?.parentNode?.spec.title,
priority: RECENT_SCOPES_PRIORITY,
perform: () => {
scopesSelectorService.changeScopes(recentScope.map((scope) => scope.metadata.name));
@@ -1,13 +1,15 @@
import { css } from '@emotion/css';
import { useId, useState } from 'react';
import { GrafanaTheme2, Scope } from '@grafana/data';
import { GrafanaTheme2 } from '@grafana/data';
import { Trans } from '@grafana/i18n';
import { useStyles2, Stack, Text, Icon, Box } from '@grafana/ui';
import { RecentScope } from './types';
interface RecentScopesProps {
recentScopes: Scope[][];
onSelect: (scopeIds: string[]) => void;
recentScopes: RecentScope[][];
onSelect: (scopeIds: string[], parentNodeId?: string) => void;
}
export const RecentScopes = ({ recentScopes, onSelect }: RecentScopesProps) => {
@@ -39,10 +41,18 @@ export const RecentScopes = ({ recentScopes, onSelect }: RecentScopesProps) => {
className={styles.recentScopeButton}
key={recentScopeSet.map((s) => s.metadata.name).join(',')}
onClick={() => {
onSelect(recentScopeSet.map((s) => s.metadata.name));
onSelect(
recentScopeSet.map((s) => s.metadata.name),
recentScopeSet[0]?.parentNode?.metadata?.name
);
}}
>
<Text>{recentScopeSet.map((s) => s.spec.title).join(', ')}</Text>
<Text truncate>{recentScopeSet.map((s) => s.spec.title).join(', ')}</Text>
{recentScopeSet[0]?.parentNode?.spec.title && (
<Text truncate variant="body" color="secondary">
{recentScopeSet[0]?.parentNode?.spec.title}
</Text>
)}
</button>
))}
</Stack>
@@ -58,9 +68,9 @@ const getStyles = (theme: GrafanaTheme2) => ({
border: 'none',
padding: 0,
cursor: 'pointer',
textOverflow: 'ellipsis',
overflow: 'hidden',
whiteSpace: 'nowrap',
display: 'flex',
gap: theme.spacing(1),
alignItems: 'center',
}),
expandButton: css({
display: 'flex',
@@ -105,8 +105,8 @@ export const ScopesSelector = () => {
scopeNodes={nodes}
selectScope={selectScope}
deselectScope={deselectScope}
onRecentScopesSelect={(scopeIds: string[]) => {
scopesSelectorService.changeScopes(scopeIds);
onRecentScopesSelect={(scopeIds: string[], parentNodeId?: string) => {
scopesSelectorService.changeScopes(scopeIds, parentNodeId);
scopesSelectorService.closeAndReset();
}}
/>
@@ -4,6 +4,7 @@ import { ScopesApiClient } from '../ScopesApiClient';
import { ScopesDashboardsService } from '../dashboards/ScopesDashboardsService';
import { RECENT_SCOPES_KEY, ScopesSelectorService } from './ScopesSelectorService';
import { RecentScope } from './types';
describe('ScopesSelectorService', () => {
let service: ScopesSelectorService;
@@ -42,6 +43,7 @@ describe('ScopesSelectorService', () => {
};
let storeValue: Record<string, unknown> = {};
let store: Store;
beforeEach(() => {
apiClient = {
@@ -63,17 +65,23 @@ describe('ScopesSelectorService', () => {
} as unknown as jest.Mocked<ScopesDashboardsService>;
storeValue = {};
const store = {
store = {
get(key: string) {
return storeValue[key];
},
set(key: string, value: string) {
storeValue[key] = value;
},
};
subscribe: jest.fn(),
notifySubscribers: jest.fn(),
getBool: jest.fn(),
getObject: jest.fn(),
setObject: jest.fn(),
exists: jest.fn(),
delete: jest.fn(),
} as unknown as Store;
service = new ScopesSelectorService(apiClient, dashboardsService, store as Store);
service = new ScopesSelectorService(apiClient, dashboardsService, store);
});
describe('updateNode', () => {
@@ -125,6 +133,10 @@ describe('ScopesSelectorService', () => {
await service.deselectScope('test-scope-node');
expect(service.state.selectedScopes).toEqual([]);
});
it('should set recent scopes', async () => {
await service.selectScope('test-scope-node');
});
});
describe('changeScopes', () => {
@@ -154,6 +166,16 @@ describe('ScopesSelectorService', () => {
sub.unsubscribe();
});
it('should set parent node for recent scopes', async () => {
// Load mock node
await service.updateNode('', true, '');
await service.changeScopes(['test-scope'], 'test-scope-node');
expect(service.state.appliedScopes).toEqual([{ scopeId: 'test-scope', parentNodeId: 'test-scope-node' }]);
expect(service.state.nodes).toEqual({ 'test-scope-node': mockNode });
expect(storeValue[RECENT_SCOPES_KEY]).toEqual(JSON.stringify([[{ ...mockScope, parentNode: mockNode }]]));
});
});
describe('open', () => {
@@ -253,4 +275,166 @@ describe('ScopesSelectorService', () => {
expect(recentScopes).toEqual([]);
});
});
describe('nodes from local storage', () => {
it('should return parent nodes from recent scopes', async () => {
// Set mock scopes with parent node
const mockScopeWithParentNode: RecentScope = {
metadata: { name: 'test-scope' },
spec: {
title: 'test-scope',
type: 'scope',
category: 'scope',
description: 'test scope',
filters: [],
},
parentNode: {
metadata: { name: 'test-scope-node' },
spec: {
linkId: 'test-scope',
linkType: 'scope',
parentName: '',
nodeType: 'container',
title: 'test-scope-node',
},
},
};
// Set store value BEFORE creating the service
storeValue[RECENT_SCOPES_KEY] = JSON.stringify([[mockScopeWithParentNode]]);
// Create service with the existing store (which now has the data)
service = new ScopesSelectorService(apiClient, dashboardsService, store as Store);
expect(service.state.nodes).toEqual({ 'test-scope-node': mockScopeWithParentNode.parentNode });
});
it('should remove parent node if it is not valid', async () => {
// Mock with valid parent node
const mockScopeWithValidParentNode: RecentScope = {
metadata: { name: 'test-scope' },
spec: {
title: 'test-scope',
type: 'scope',
category: 'scope',
description: 'test scope',
filters: [],
},
parentNode: {
metadata: { name: 'test-scope-node' },
spec: {
linkId: 'test-scope',
linkType: 'scope',
parentName: '',
nodeType: 'container',
title: 'test-scope-node',
},
},
};
// lacks name and spec
const mockScopeWithInvalidParentNode: RecentScope = {
metadata: { name: 'test-scope' },
spec: {
title: 'test-scope',
type: 'scope',
category: 'scope',
description: 'test scope',
filters: [],
},
parentNode: {
//@ts-expect-error
metadata: {},
//@ts-expect-error
spec: {},
},
};
// Set store value BEFORE creating the service
storeValue[RECENT_SCOPES_KEY] = JSON.stringify([
[mockScopeWithInvalidParentNode],
[mockScopeWithValidParentNode],
]);
// Create service with the existing store (which now has the data)
service = new ScopesSelectorService(apiClient, dashboardsService, store as Store);
expect(service.state.nodes).toEqual({ 'test-scope-node': mockScopeWithValidParentNode.parentNode });
});
it('should validate parent nodes across all recent scope sets', async () => {
// Create multiple scope sets with various parent node validity
const mockScopeWithValidParentNode: RecentScope = {
metadata: { name: 'valid-scope' },
spec: {
title: 'valid-scope',
type: 'scope',
category: 'scope',
description: 'valid scope',
filters: [],
},
parentNode: {
metadata: { name: 'valid-parent-node' },
spec: {
linkId: 'valid-scope',
linkType: 'scope',
parentName: '',
nodeType: 'container',
title: 'valid-parent-node',
},
},
};
const mockScopeWithInvalidParentNode1: RecentScope = {
metadata: { name: 'invalid-scope-1' },
spec: {
title: 'invalid-scope-1',
type: 'scope',
category: 'scope',
description: 'invalid scope 1',
filters: [],
},
parentNode: {
//@ts-expect-error
metadata: {},
//@ts-expect-error
spec: {},
},
};
const mockScopeWithInvalidParentNode2: RecentScope = {
metadata: { name: 'invalid-scope-2' },
spec: {
title: 'invalid-scope-2',
type: 'scope',
category: 'scope',
description: 'invalid scope 2',
filters: [],
},
parentNode: {
metadata: { name: 'invalid-parent-node-2' }, // missing spec
//@ts-expect-error - intentionally invalid spec for testing
spec: {},
},
};
// Set store value with multiple scope sets - some with invalid parent nodes
storeValue[RECENT_SCOPES_KEY] = JSON.stringify([
[mockScopeWithInvalidParentNode1],
[mockScopeWithValidParentNode],
[mockScopeWithInvalidParentNode2],
]);
// Create service with the existing store
service = new ScopesSelectorService(apiClient, dashboardsService, store as Store);
// Should only include the valid parent node
expect(service.state.nodes).toEqual({ 'valid-parent-node': mockScopeWithValidParentNode.parentNode });
// Verify that the invalid parent nodes were removed from the stored data
const recentScopes = service.getRecentScopes();
expect(recentScopes).toHaveLength(3);
expect(recentScopes[0][0].parentNode).toBeUndefined(); // invalid parent node should be removed
expect(recentScopes[1][0].parentNode).toEqual(mockScopeWithValidParentNode.parentNode); // valid parent node should remain
expect(recentScopes[2][0].parentNode).toBeUndefined(); // invalid parent node should be removed
});
});
});
@@ -1,4 +1,4 @@
import { Scope, store as storeImpl } from '@grafana/data';
import { Scope, ScopeNode, store as storeImpl } from '@grafana/data';
import { ScopesApiClient } from '../ScopesApiClient';
import { ScopesServiceBase } from '../ScopesServiceBase';
@@ -13,7 +13,7 @@ import {
modifyTreeNodeAtPath,
treeNodeAtPath,
} from './scopesTreeUtils';
import { NodesMap, ScopesMap, SelectedScope, TreeNode } from './types';
import { NodesMap, RecentScope, RecentScopeSchema, ScopeSchema, ScopesMap, SelectedScope, TreeNode } from './types';
export const RECENT_SCOPES_KEY = 'grafana.scopes.recent';
@@ -69,6 +69,10 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
children: undefined,
},
});
// Load nodes from recent scopes so they are readily available
const parentNodes = this.getNodesFromRecentScopes();
this.updateState({ nodes: { ...this.state.nodes, ...parentNodes } });
}
// Loads a node from the API and adds it to the nodes cache
@@ -237,7 +241,7 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
};
changeScopes = (scopeNames: string[], parentNodeId?: string) => {
return this.applyScopes(scopeNames.map((id) => ({ scopeId: id, parentNodeId: parentNodeId })));
return this.applyScopes(scopeNames.map((id) => ({ scopeId: id, parentNodeId })));
};
/**
@@ -266,22 +270,37 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
for (const scope of fetchedScopes) {
newScopesState[scope.metadata.name] = scope;
}
this.addRecentScopes(fetchedScopes);
const scopeNode = scopes[0]?.scopeNodeId ? this.state.nodes[scopes[0]?.scopeNodeId] : undefined;
// If parentNodeId is provided, use it directly as the parent node
// If not provided, try to get the parent from the scope node
// When selected from recent scopes, we don't have access to the scope node (if it hasn't been loaded), but we do have access to the parent node from local storage.
const parentNodeId = scopes[0]?.parentNodeId || scopeNode?.spec.parentName;
const parentNode = parentNodeId ? this.state.nodes[parentNodeId] : undefined;
this.addRecentScopes(fetchedScopes, parentNode);
this.updateState({ scopes: newScopesState, loading: false });
}
};
public removeAllScopes = () => this.applyScopes([]);
private addRecentScopes = (scopes: Scope[]) => {
private addRecentScopes = (scopes: Scope[], parentNode?: ScopeNode) => {
if (scopes.length === 0) {
return;
}
const newScopes: RecentScope[] = structuredClone(scopes);
// Set parent node for the first scope. We don't currently support multiple parent nodes being displayed, hence we only add for the first one
if (parentNode) {
newScopes[0].parentNode = parentNode;
}
const RECENT_SCOPES_MAX_LENGTH = 5;
const recentScopes = this.getRecentScopes();
recentScopes.unshift(scopes);
recentScopes.unshift(newScopes);
this.store.set(RECENT_SCOPES_KEY, JSON.stringify(recentScopes.slice(0, RECENT_SCOPES_MAX_LENGTH - 1)));
};
@@ -289,12 +308,12 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
* Returns recent scopes from local storage. It is array of array cause each item can represent application of
* multiple different scopes.
*/
public getRecentScopes = (): Scope[][] => {
public getRecentScopes = (): RecentScope[][] => {
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
return recentScopes.filter((scopes: Scope[]) => {
return recentScopes.filter((scopes: RecentScope[]) => {
if (scopes.length !== this.state.appliedScopes.length) {
return true;
}
@@ -303,6 +322,20 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
});
};
private getNodesFromRecentScopes = (): Record<string, ScopeNode> => {
const content: string | undefined = this.store.get(RECENT_SCOPES_KEY);
const recentScopes = parseScopesFromLocalStorage(content);
// Load parent nodes for recent scopes
const parentNodes = Object.fromEntries(
recentScopes
.map((scopes) => [scopes[0]?.parentNode?.metadata?.name, scopes[0]?.parentNode])
.filter(([key, parentNode]) => parentNode !== undefined && key !== undefined)
);
return parentNodes;
};
/**
* Opens the scopes selector drawer and loads the root nodes if they are not loaded yet.
*/
@@ -361,18 +394,14 @@ function isScopeLocalStorageV1(obj: unknown): obj is { scope: Scope } {
}
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
);
return ScopeSchema.safeParse(obj).success;
}
function parseScopesFromLocalStorage(content: string | undefined): Scope[][] {
function hasValidScopeParentNode(obj: unknown): obj is RecentScope {
return RecentScopeSchema.safeParse(obj).success;
}
function parseScopesFromLocalStorage(content: string | undefined): RecentScope[][] {
let recentScopes;
try {
recentScopes = JSON.parse(content || '[]');
@@ -391,5 +420,14 @@ function parseScopesFromLocalStorage(content: string | undefined): Scope[][] {
return [];
}
// Verify the structure of the parent node for all recent scope sets, and remove it if it is not valid
for (const scopeSet of recentScopes) {
if (scopeSet[0]?.parentNode) {
if (!hasValidScopeParentNode(scopeSet[0])) {
scopeSet[0].parentNode = undefined;
}
}
}
return recentScopes;
}
@@ -23,7 +23,7 @@ export interface ScopesTreeProps {
// Recent scopes are only shown at the root node
recentScopes?: Scope[][];
onRecentScopesSelect?: (scopeIds: string[]) => void;
onRecentScopesSelect?: (scopeIds: string[], parentNodeId?: string) => void;
}
export function ScopesTree({
@@ -1,3 +1,5 @@
import { z } from 'zod';
import { Scope, ScopeNode } from '@grafana/data';
export type NodesMap = Record<string, ScopeNode>;
@@ -16,3 +18,51 @@ export interface TreeNode {
query: string;
children?: Record<string, TreeNode>;
}
export interface RecentScope extends Scope {
parentNode?: ScopeNode;
}
// Zod schemas for type validation
export const ScopeSpecFilterSchema = z.object({
key: z.string(),
value: z.string(),
values: z.array(z.string()).optional(),
operator: z.enum(['equals', 'not-equals', 'regex-match', 'regex-not-match', 'one-of', 'not-one-of']),
});
export const ScopeSpecSchema = z.object({
title: z.string(),
type: z.string(),
description: z.string(),
category: z.string(),
filters: z.array(ScopeSpecFilterSchema),
});
export const ScopeSchema = z.object({
metadata: z.object({
name: z.string(),
}),
spec: ScopeSpecSchema,
});
export const ScopeNodeSpecSchema = z.object({
nodeType: z.enum(['container', 'leaf']),
title: z.string(),
description: z.string().optional(),
disableMultiSelect: z.boolean().optional(),
linkId: z.string().optional(),
linkType: z.enum(['scope']).optional(),
parentName: z.string().optional(),
});
export const ScopeNodeSchema = z.object({
metadata: z.object({
name: z.string(),
}),
spec: ScopeNodeSpecSchema,
});
export const RecentScopeSchema = ScopeSchema.extend({
parentNode: ScopeNodeSchema.optional(),
});
@@ -102,18 +102,18 @@ describe('Selector', () => {
await openSelector();
expectRecentScopesSection();
await expandRecentScopes();
expectRecentScope('Grafana');
expectRecentScope('Grafana, Mimir');
await selectRecentScope('Grafana');
expectRecentScope('Grafana Applications');
expectRecentScope('Grafana, Mimir Applications');
await selectRecentScope('Grafana Applications');
expectScopesSelectorValue('Grafana');
await openSelector();
await expandRecentScopes();
expectRecentScope('Grafana, Mimir');
expectRecentScopeNotPresent('Grafana');
expectRecentScopeNotPresent('Mimir');
await selectRecentScope('Grafana, Mimir');
expectRecentScope('Grafana, Mimir Applications');
expectRecentScopeNotPresent('Grafana Applications');
expectRecentScopeNotPresent('Mimir Applications');
await selectRecentScope('Grafana, Mimir Applications');
expectScopesSelectorValue('Grafana + Mimir');
});
@@ -148,7 +148,7 @@ describe('Selector', () => {
await openSelector();
expectRecentScopesSection();
await expandRecentScopes();
expectRecentScope('Grafana, Mimir');
expectRecentScope('Grafana, Mimir Applications');
});
it('should update recent scopes when selecting a different combination', async () => {
@@ -169,8 +169,8 @@ describe('Selector', () => {
// Check recent scopes are updated
await openSelector();
await expandRecentScopes();
expectRecentScope('Grafana, Mimir');
expectRecentScope('Grafana');
expectRecentScope('Grafana, Mimir Applications');
expectRecentScope('Grafana Applications');
});
});
});