mirror of https://github.com/grafana/grafana.git
560 lines
20 KiB
TypeScript
560 lines
20 KiB
TypeScript
import { Scope, ScopeNode, Store } from '@grafana/data';
|
|
|
|
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;
|
|
let apiClient: jest.Mocked<ScopesApiClient>;
|
|
let dashboardsService: jest.Mocked<ScopesDashboardsService>;
|
|
|
|
const mockScope: Scope = {
|
|
metadata: {
|
|
name: 'test-scope',
|
|
},
|
|
spec: {
|
|
title: 'test-scope',
|
|
type: 'scope',
|
|
description: 'test scope',
|
|
category: 'scope',
|
|
filters: [],
|
|
},
|
|
};
|
|
|
|
const mockScope2: Scope = {
|
|
metadata: {
|
|
name: 'recent-scope',
|
|
},
|
|
spec: {
|
|
title: 'test-scope',
|
|
type: 'scope',
|
|
category: 'scope',
|
|
description: 'test scope',
|
|
filters: [],
|
|
},
|
|
};
|
|
|
|
const mockNode: ScopeNode = {
|
|
metadata: { name: 'test-scope-node' },
|
|
spec: { linkId: 'test-scope', linkType: 'scope', parentName: '', nodeType: 'leaf', title: 'test-scope-node' },
|
|
};
|
|
|
|
let storeValue: Record<string, unknown> = {};
|
|
let store: Store;
|
|
|
|
beforeEach(() => {
|
|
apiClient = {
|
|
fetchScope: jest.fn().mockResolvedValue(mockScope),
|
|
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<ScopesApiClient>;
|
|
|
|
dashboardsService = {
|
|
fetchDashboards: jest.fn(),
|
|
} as unknown as jest.Mocked<ScopesDashboardsService>;
|
|
|
|
storeValue = {};
|
|
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);
|
|
});
|
|
|
|
describe('updateNode', () => {
|
|
it('should update node and fetch children when expanded', async () => {
|
|
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.skip('should update node query and fetch children when query changes', async () => {
|
|
await service.updateNode('', true, ''); // Expand first
|
|
// Simulate a change in the query
|
|
await service.updateNode('', true, 'new-qu');
|
|
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, '');
|
|
// Then collapse it
|
|
await service.updateNode('', false, '');
|
|
// Only the first expansion should trigger fetchNodes
|
|
expect(apiClient.fetchNodes).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it.skip('should clear query on first expansion but keep it when filtering within populated node', async () => {
|
|
const mockChildNode: ScopeNode = {
|
|
metadata: { name: 'child-node' },
|
|
spec: { linkId: 'child-scope', linkType: 'scope', parentName: '', nodeType: 'leaf', title: 'child-node' },
|
|
};
|
|
|
|
apiClient.fetchNodes.mockResolvedValue([mockChildNode]);
|
|
|
|
// Scenario 1: First expansion (no children yet) - clear query for unfiltered view
|
|
await service.updateNode('', true, 'search-query');
|
|
expect(apiClient.fetchNodes).toHaveBeenCalledWith({ parent: '', query: undefined });
|
|
|
|
// Parent query should be cleared and child nodes should have no query (first expansion)
|
|
expect(service.state.tree?.query).toBe('');
|
|
let childTreeNode = service.state.tree?.children?.['child-node'];
|
|
expect(childTreeNode?.query).toBe('');
|
|
|
|
// Scenario 2: Filtering within node that already has children
|
|
await service.updateNode('', true, 'new-search');
|
|
expect(apiClient.fetchNodes).toHaveBeenCalledWith({ parent: '', query: 'new-search' });
|
|
|
|
// Parent and child nodes should have the filter query (filtering within existing children)
|
|
expect(service.state.tree?.query).toBe('new-search');
|
|
childTreeNode = service.state.tree?.children?.['child-node'];
|
|
expect(childTreeNode?.query).toBe('new-search');
|
|
|
|
expect(apiClient.fetchNodes).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it.skip('should always reset query on any expansion', async () => {
|
|
const mockChildNode: ScopeNode = {
|
|
metadata: { name: 'child-node' },
|
|
spec: { linkId: 'child-scope', linkType: 'scope', parentName: '', nodeType: 'leaf', title: 'child-node' },
|
|
};
|
|
|
|
apiClient.fetchNodes.mockResolvedValue([mockChildNode]);
|
|
|
|
// First expansion with any query should reset parent query and not pass query to API
|
|
await service.updateNode('', true, 'some-search-query');
|
|
|
|
// Verify query is reset and API called without query for first expansion
|
|
expect(service.state.tree?.query).toBe('');
|
|
expect(apiClient.fetchNodes).toHaveBeenCalledWith({ parent: '', query: undefined });
|
|
expect(service.state.tree?.children?.['child-node']?.query).toBe('');
|
|
});
|
|
|
|
it.skip('should handle query reset correctly for nested levels beyond root', async () => {
|
|
// Set up mock nodes for multi-level hierarchy
|
|
const mockParentNode: ScopeNode = {
|
|
metadata: { name: 'parent-container' },
|
|
spec: { linkId: '', linkType: 'scope', parentName: '', nodeType: 'container', title: 'Parent Container' },
|
|
};
|
|
|
|
const mockChildNode: ScopeNode = {
|
|
metadata: { name: 'child-container' },
|
|
spec: {
|
|
linkId: '',
|
|
linkType: 'scope',
|
|
parentName: 'parent-container',
|
|
nodeType: 'container',
|
|
title: 'Child Container',
|
|
},
|
|
};
|
|
|
|
const mockGrandchildNode: ScopeNode = {
|
|
metadata: { name: 'grandchild-leaf' },
|
|
spec: {
|
|
linkId: 'leaf-scope',
|
|
linkType: 'scope',
|
|
parentName: 'child-container',
|
|
nodeType: 'leaf',
|
|
title: 'Grandchild Leaf',
|
|
},
|
|
};
|
|
|
|
// Mock different responses for different parent nodes
|
|
apiClient.fetchNodes.mockImplementation((options: { parent?: string; query?: string; limit?: number }) => {
|
|
if (options.parent === '') {
|
|
return Promise.resolve([mockParentNode]);
|
|
} else if (options.parent === 'parent-container') {
|
|
return Promise.resolve([mockChildNode]);
|
|
} else if (options.parent === 'child-container') {
|
|
return Promise.resolve([mockGrandchildNode]);
|
|
}
|
|
return Promise.resolve([]);
|
|
});
|
|
|
|
// Step 1: Expand root node with search query
|
|
await service.updateNode('', true, 'search-query');
|
|
|
|
// Root should have query reset, API called without query
|
|
expect(service.state.tree?.query).toBe('');
|
|
expect(apiClient.fetchNodes).toHaveBeenCalledWith({ parent: '', query: undefined });
|
|
expect(service.state.tree?.children?.['parent-container']?.query).toBe('');
|
|
|
|
// Step 2: Expand first-level child with search query
|
|
await service.updateNode('parent-container', true, 'open-search-query');
|
|
|
|
// First-level child should have query reset, API called without query
|
|
const parentContainer = service.state.tree?.children?.['parent-container'];
|
|
expect(parentContainer?.query).toBe('');
|
|
expect(apiClient.fetchNodes).toHaveBeenCalledWith({ parent: 'parent-container', query: undefined });
|
|
expect(parentContainer?.children?.['child-container']?.query).toBe('');
|
|
|
|
// Step 3: Now filter within the first-level child (second call to same node)
|
|
await service.updateNode('parent-container', true, 'filter-search');
|
|
|
|
// Now both parent and children should show the filter query since we're filtering within existing children
|
|
const newParentContainer = service.state.tree?.children?.['parent-container'];
|
|
expect(newParentContainer?.query).toBe('filter-search');
|
|
expect(apiClient.fetchNodes).toHaveBeenCalledWith({ parent: 'parent-container', query: 'filter-search' });
|
|
expect(newParentContainer?.children?.['child-container']?.query).toBe('filter-search');
|
|
|
|
expect(apiClient.fetchNodes).toHaveBeenCalledTimes(3);
|
|
});
|
|
});
|
|
|
|
describe('selectScope and deselectScope', () => {
|
|
beforeEach(async () => {
|
|
await service.updateNode('', true, '');
|
|
});
|
|
|
|
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 selected scope', async () => {
|
|
await service.selectScope('test-scope-node');
|
|
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', () => {
|
|
it('should apply the provided scope names', async () => {
|
|
await service.changeScopes(['test-scope']);
|
|
expect(service.state.appliedScopes).toEqual([{ scopeId: '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();
|
|
});
|
|
|
|
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', () => {
|
|
it('should open the selector and load root nodes if not loaded', async () => {
|
|
await service.open();
|
|
expect(service.state.opened).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('closeAndReset', () => {
|
|
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.selectedScopes).toEqual(service.state.appliedScopes);
|
|
});
|
|
});
|
|
|
|
describe('closeAndApply', () => {
|
|
it('should close the selector and apply the selected scopes', async () => {
|
|
await service.updateNode('', true, '');
|
|
await service.selectScope('test-scope-node');
|
|
await service.closeAndApply();
|
|
expect(service.state.opened).toBe(false);
|
|
expect(service.state.appliedScopes).toEqual(service.state.selectedScopes);
|
|
});
|
|
});
|
|
|
|
describe('apply', () => {
|
|
it('should apply the selected scopes without closing the selector', async () => {
|
|
await service.open();
|
|
await service.selectScope('test-scope-node');
|
|
await service.apply();
|
|
expect(service.state.opened).toBe(true);
|
|
expect(service.state.appliedScopes).toEqual(service.state.selectedScopes);
|
|
});
|
|
});
|
|
|
|
describe('resetSelection', () => {
|
|
it('should reset selectedScopes to match appliedScopes', async () => {
|
|
await service.changeScopes(['test-scope']);
|
|
service.resetSelection();
|
|
expect(service.state.selectedScopes).toEqual(service.state.appliedScopes);
|
|
});
|
|
});
|
|
|
|
describe('removeAllScopes', () => {
|
|
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([]);
|
|
});
|
|
});
|
|
|
|
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([]);
|
|
});
|
|
});
|
|
|
|
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
|
|
});
|
|
});
|
|
});
|