Chore: Finalize removal of updateNode & expandOrFilter (#114202)
Some checks are pending
Actionlint / Lint GitHub Actions files (push) Waiting to run
Backend Code Checks / Detect whether code changed (push) Waiting to run
Backend Code Checks / Validate Backend Configs (push) Blocked by required conditions
Backend Unit Tests / Detect whether code changed (push) Waiting to run
Backend Unit Tests / Grafana (1/8) (push) Blocked by required conditions
Backend Unit Tests / Grafana (2/8) (push) Blocked by required conditions
Backend Unit Tests / Grafana (3/8) (push) Blocked by required conditions
Backend Unit Tests / Grafana (4/8) (push) Blocked by required conditions
Backend Unit Tests / Grafana (5/8) (push) Blocked by required conditions
Backend Unit Tests / Grafana (6/8) (push) Blocked by required conditions
Backend Unit Tests / Grafana (7/8) (push) Blocked by required conditions
Backend Unit Tests / Grafana (8/8) (push) Blocked by required conditions
Backend Unit Tests / Grafana Enterprise (1/8) (push) Blocked by required conditions
Backend Unit Tests / Grafana Enterprise (2/8) (push) Blocked by required conditions
Backend Unit Tests / Grafana Enterprise (3/8) (push) Blocked by required conditions
Backend Unit Tests / Grafana Enterprise (4/8) (push) Blocked by required conditions
Backend Unit Tests / Grafana Enterprise (5/8) (push) Blocked by required conditions
Backend Unit Tests / Grafana Enterprise (6/8) (push) Blocked by required conditions
Backend Unit Tests / Grafana Enterprise (7/8) (push) Blocked by required conditions
Backend Unit Tests / Grafana Enterprise (8/8) (push) Blocked by required conditions
Backend Unit Tests / All backend unit tests complete (push) Blocked by required conditions
CodeQL checks / Detect whether code changed (push) Waiting to run
CodeQL checks / Analyze (push) Blocked by required conditions
Lint Frontend / Detect whether code changed (push) Waiting to run
Lint Frontend / Lint (push) Blocked by required conditions
Lint Frontend / Typecheck (push) Blocked by required conditions
Lint Frontend / Verify API clients (push) Waiting to run
Lint Frontend / Verify API clients (enterprise) (push) Waiting to run
golangci-lint / Detect whether code changed (push) Waiting to run
golangci-lint / go-fmt (push) Blocked by required conditions
golangci-lint / lint-go (push) Blocked by required conditions
Crowdin Upload Action / upload-sources-to-crowdin (push) Waiting to run
Verify i18n / verify-i18n (push) Waiting to run
Documentation / Build & Verify Docs (push) Waiting to run
End-to-end tests / Detect whether code changed (push) Waiting to run
End-to-end tests / Build & Package Grafana (push) Blocked by required conditions
End-to-end tests / Build E2E test runner (push) Blocked by required conditions
End-to-end tests / push-docker-image (push) Blocked by required conditions
End-to-end tests / dashboards-suite (old arch) (push) Blocked by required conditions
End-to-end tests / panels-suite (old arch) (push) Blocked by required conditions
End-to-end tests / smoke-tests-suite (old arch) (push) Blocked by required conditions
End-to-end tests / various-suite (old arch) (push) Blocked by required conditions
End-to-end tests / Verify Storybook (Playwright) (push) Blocked by required conditions
End-to-end tests / Playwright E2E tests (1/8) (push) Blocked by required conditions
End-to-end tests / Playwright E2E tests (2/8) (push) Blocked by required conditions
End-to-end tests / Playwright E2E tests (3/8) (push) Blocked by required conditions
End-to-end tests / Playwright E2E tests (4/8) (push) Blocked by required conditions
End-to-end tests / Playwright E2E tests (5/8) (push) Blocked by required conditions
End-to-end tests / Playwright E2E tests (6/8) (push) Blocked by required conditions
End-to-end tests / Playwright E2E tests (7/8) (push) Blocked by required conditions
End-to-end tests / Playwright E2E tests (8/8) (push) Blocked by required conditions
End-to-end tests / run-azure-monitor-e2e (push) Blocked by required conditions
End-to-end tests / All Playwright tests complete (push) Blocked by required conditions
End-to-end tests / A11y test (push) Blocked by required conditions
End-to-end tests / Publish metrics (push) Blocked by required conditions
End-to-end tests / All E2E tests complete (push) Blocked by required conditions
Frontend tests / Detect whether code changed (push) Waiting to run
Frontend tests / Unit tests (1 / 16) (push) Blocked by required conditions
Frontend tests / Unit tests (10 / 16) (push) Blocked by required conditions
Frontend tests / Unit tests (11 / 16) (push) Blocked by required conditions
Frontend tests / Unit tests (12 / 16) (push) Blocked by required conditions
Frontend tests / Unit tests (13 / 16) (push) Blocked by required conditions
Frontend tests / Unit tests (14 / 16) (push) Blocked by required conditions
Frontend tests / Unit tests (15 / 16) (push) Blocked by required conditions
Frontend tests / Unit tests (16 / 16) (push) Blocked by required conditions
Frontend tests / Unit tests (2 / 16) (push) Blocked by required conditions
Frontend tests / Unit tests (3 / 16) (push) Blocked by required conditions
Frontend tests / Unit tests (4 / 16) (push) Blocked by required conditions
Frontend tests / Unit tests (5 / 16) (push) Blocked by required conditions
Frontend tests / Unit tests (6 / 16) (push) Blocked by required conditions
Frontend tests / Unit tests (7 / 16) (push) Blocked by required conditions
Frontend tests / Unit tests (8 / 16) (push) Blocked by required conditions
Frontend tests / Unit tests (9 / 16) (push) Blocked by required conditions
Frontend tests / Decoupled plugin tests (push) Blocked by required conditions
Frontend tests / Packages unit tests (push) Blocked by required conditions
Frontend tests / All frontend unit tests complete (push) Blocked by required conditions
Frontend tests / Devenv frontend-service build (push) Blocked by required conditions
Integration Tests / Detect whether code changed (push) Waiting to run
Integration Tests / Sqlite (1/4) (push) Blocked by required conditions
Integration Tests / Sqlite (2/4) (push) Blocked by required conditions
Integration Tests / Sqlite (3/4) (push) Blocked by required conditions
Integration Tests / Sqlite (4/4) (push) Blocked by required conditions
Integration Tests / Sqlite Without CGo (1/4) (push) Blocked by required conditions
Integration Tests / Sqlite Without CGo (2/4) (push) Blocked by required conditions
Integration Tests / Sqlite Without CGo (3/4) (push) Blocked by required conditions
Integration Tests / Sqlite Without CGo (4/4) (push) Blocked by required conditions
Integration Tests / Sqlite Without CGo (profiled) (push) Blocked by required conditions
Integration Tests / MySQL (1/16) (push) Blocked by required conditions
Integration Tests / MySQL (10/16) (push) Blocked by required conditions
Integration Tests / MySQL (11/16) (push) Blocked by required conditions
Integration Tests / MySQL (12/16) (push) Blocked by required conditions
Integration Tests / MySQL (13/16) (push) Blocked by required conditions
Integration Tests / MySQL (14/16) (push) Blocked by required conditions
Integration Tests / MySQL (15/16) (push) Blocked by required conditions
Integration Tests / MySQL (16/16) (push) Blocked by required conditions
Integration Tests / MySQL (2/16) (push) Blocked by required conditions
Integration Tests / MySQL (3/16) (push) Blocked by required conditions
Integration Tests / MySQL (4/16) (push) Blocked by required conditions
Integration Tests / MySQL (5/16) (push) Blocked by required conditions
Integration Tests / MySQL (6/16) (push) Blocked by required conditions
Integration Tests / MySQL (7/16) (push) Blocked by required conditions
Integration Tests / MySQL (8/16) (push) Blocked by required conditions
Integration Tests / MySQL (9/16) (push) Blocked by required conditions
Integration Tests / Postgres (1/16) (push) Blocked by required conditions
Integration Tests / Postgres (10/16) (push) Blocked by required conditions
Integration Tests / Postgres (11/16) (push) Blocked by required conditions
Integration Tests / Postgres (12/16) (push) Blocked by required conditions
Integration Tests / Postgres (13/16) (push) Blocked by required conditions
Integration Tests / Postgres (14/16) (push) Blocked by required conditions
Integration Tests / Postgres (15/16) (push) Blocked by required conditions
Integration Tests / Postgres (16/16) (push) Blocked by required conditions
Integration Tests / Postgres (2/16) (push) Blocked by required conditions
Integration Tests / Postgres (3/16) (push) Blocked by required conditions
Integration Tests / Postgres (4/16) (push) Blocked by required conditions
Integration Tests / Postgres (5/16) (push) Blocked by required conditions
Integration Tests / Postgres (6/16) (push) Blocked by required conditions
Integration Tests / Postgres (7/16) (push) Blocked by required conditions
Integration Tests / Postgres (8/16) (push) Blocked by required conditions
Integration Tests / Postgres (9/16) (push) Blocked by required conditions
Integration Tests / All backend integration tests complete (push) Blocked by required conditions
publish-technical-documentation-next / sync (push) Waiting to run
Reject GitHub secrets / reject-gh-secrets (push) Waiting to run
Build Release Packages / setup (push) Waiting to run
Build Release Packages / Dispatch grafana-enterprise build (push) Blocked by required conditions
Build Release Packages / / darwin-amd64 (push) Blocked by required conditions
Build Release Packages / / darwin-arm64 (push) Blocked by required conditions
Build Release Packages / / linux-amd64 (push) Blocked by required conditions
Build Release Packages / / linux-armv6 (push) Blocked by required conditions
Build Release Packages / / linux-armv7 (push) Blocked by required conditions
Build Release Packages / / linux-arm64 (push) Blocked by required conditions
Build Release Packages / / linux-s390x (push) Blocked by required conditions
Build Release Packages / / windows-amd64 (push) Blocked by required conditions
Build Release Packages / / windows-arm64 (push) Blocked by required conditions
Build Release Packages / Upload artifacts (push) Blocked by required conditions
Build Release Packages / publish-dockerhub (push) Blocked by required conditions
Build Release Packages / Dispatch publish NPM canaries (push) Blocked by required conditions
Build Release Packages / notify-pr (push) Blocked by required conditions
Run dashboard schema v2 e2e / dashboard-schema-v2-e2e (push) Waiting to run
Shellcheck / Shellcheck scripts (push) Waiting to run
Run Storybook a11y tests / Detect whether code changed (push) Waiting to run
Run Storybook a11y tests / Run Storybook a11y tests (light theme) (push) Blocked by required conditions
Run Storybook a11y tests / Run Storybook a11y tests (dark theme) (push) Blocked by required conditions
Swagger generated code / Detect whether code changed (push) Waiting to run
Swagger generated code / Verify committed API specs match (push) Blocked by required conditions
Dispatch sync to mirror / dispatch-job (push) Waiting to run

- Remove references to, and related private functions for, `updateNode` and `expandOrFilter`
- Remove obsolete tests
- Update all usages of `updateNode` to `filterNode`
- Integrate `expandOrFilter` functionality into `filterNode`
- Add profiler to `filterNode`
- Add `.claude` to `.gitignore` IDE junk section
- Unit tests for `toggleExpandedNode` and `filterNode`
- Add profiler to `toggleExpandedNode`

Fixes: https://github.com/grafana/grafana-operator-experience-squad/issues/1566
This commit is contained in:
Eric Shields 2025-11-26 15:47:32 -08:00 committed by GitHub
parent a8aef11926
commit 84a07be6e4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 425 additions and 289 deletions

1
.gitignore vendored
View file

@ -71,6 +71,7 @@ public/css/*.min.css
.vs/
.cursor/
.devcontainer/
.claude/
.eslintcache
.stylelintcache

View file

@ -22,7 +22,7 @@ jest.mock('./scopesUtils', () => {
});
const mockScopeServicesState = {
updateNode: jest.fn(),
filterNode: jest.fn(),
selectScope: jest.fn(),
resetSelection: jest.fn(),
nodes: {},
@ -99,12 +99,12 @@ describe('useRegisterScopesActions', () => {
});
it('should register scope tree actions and return scopesRow when scopes are selected', () => {
const mockUpdateNode = jest.fn();
const mockFilterNode = jest.fn();
// First run with empty scopes in the scopes service
(useScopeServicesState as jest.Mock).mockReturnValue({
...mockScopeServicesState,
updateNode: mockUpdateNode,
filterNode: mockFilterNode,
selectedScopes: [{ scopeId: 'scope1', name: 'Scope 1' }],
});
@ -112,14 +112,14 @@ describe('useRegisterScopesActions', () => {
return useRegisterScopesActions('', jest.fn());
});
expect(mockUpdateNode).toHaveBeenCalledWith('', true, '');
expect(mockFilterNode).toHaveBeenCalledWith('', '');
expect(useRegisterActions).toHaveBeenLastCalledWith([rootScopeAction], [[rootScopeAction]]);
expect(result.current.scopesRow).toBeDefined();
// Simulate loading of scopes in the service
(useScopeServicesState as jest.Mock).mockReturnValue({
...mockScopeServicesState,
updateNode: mockUpdateNode,
filterNode: mockFilterNode,
selectedScopes: [{ scopeId: 'scope1', name: 'Scope 1' }],
nodes,
tree,
@ -151,12 +151,12 @@ describe('useRegisterScopesActions', () => {
});
it('should load next level of scopes', () => {
const mockUpdateNode = jest.fn();
const mockFilterNode = jest.fn();
// First run with empty scopes in the scopes service
(useScopeServicesState as jest.Mock).mockReturnValue({
...mockScopeServicesState,
updateNode: mockUpdateNode,
filterNode: mockFilterNode,
nodes,
tree,
});
@ -165,7 +165,7 @@ describe('useRegisterScopesActions', () => {
return useRegisterScopesActions('', jest.fn(), 'scopes/scope1');
});
expect(mockUpdateNode).toHaveBeenCalledWith('scope1', true, '');
expect(mockFilterNode).toHaveBeenCalledWith('scope1', '');
});
it('does not return component if no scopes are selected', () => {
@ -259,12 +259,12 @@ describe('useRegisterScopesActions', () => {
});
it('should not use global scope search when searching in some deeper scope category', async () => {
const mockUpdateNode = jest.fn();
const mockFilterNode = jest.fn();
// First run with empty scopes in the scopes service
(useScopeServicesState as jest.Mock).mockReturnValue({
...mockScopeServicesState,
updateNode: mockUpdateNode,
filterNode: mockFilterNode,
nodes,
tree,
});
@ -273,17 +273,17 @@ describe('useRegisterScopesActions', () => {
return useRegisterScopesActions('something', jest.fn(), 'scopes/scope1');
});
expect(mockUpdateNode).toHaveBeenCalledWith('scope1', true, 'something');
expect(mockFilterNode).toHaveBeenCalledWith('scope1', 'something');
expect(mockScopeServicesState.searchAllNodes).not.toHaveBeenCalled();
});
it('should not use global scope search if feature flag is off', async () => {
config.featureToggles.scopeSearchAllLevels = false;
const mockUpdateNode = jest.fn();
const mockFilterNode = jest.fn();
// First run with empty scopes in the scopes service
(useScopeServicesState as jest.Mock).mockReturnValue({
...mockScopeServicesState,
updateNode: mockUpdateNode,
filterNode: mockFilterNode,
nodes,
tree,
});
@ -292,7 +292,7 @@ describe('useRegisterScopesActions', () => {
return useRegisterScopesActions('something', jest.fn(), '');
});
expect(mockUpdateNode).toHaveBeenCalledWith('', true, 'something');
expect(mockFilterNode).toHaveBeenCalledWith('', 'something');
expect(mockScopeServicesState.searchAllNodes).not.toHaveBeenCalled();
});

View file

@ -58,22 +58,22 @@ export function useRegisterScopesActions(
* @param parentId
*/
function useScopeTreeActions(searchQuery: string, parentId?: string | null) {
const { updateNode, selectScope, resetSelection, nodes, tree, selectedScopes } = useScopeServicesState();
const { filterNode, selectScope, resetSelection, nodes, tree, selectedScopes } = useScopeServicesState();
// Initialize the scopes the first time this runs and reset the scopes that were selected on unmount.
useEffect(() => {
updateNode('', true, '');
filterNode('', '');
resetSelection();
return () => {
resetSelection();
};
}, [updateNode, resetSelection]);
}, [filterNode, resetSelection]);
// Load the next level of scopes when the parentId changes.
useEffect(() => {
const parentScopeId = !parentId || parentId === 'scopes' ? '' : last(parentId.split('/'))!;
updateNode(parentScopeId, true, searchQuery);
}, [updateNode, searchQuery, parentId]);
filterNode(parentScopeId, searchQuery);
}, [filterNode, searchQuery, parentId]);
return useMemo(
() => mapScopesNodesTreeToActions(nodes, tree!, selectedScopes, selectScope),

View file

@ -14,7 +14,7 @@ export function useScopeServicesState() {
const services = useScopesServices();
if (!services) {
return {
updateNode: () => {},
filterNode: () => Promise.resolve(),
selectScope: () => {},
resetSelection: () => {},
searchAllNodes: () => Promise.resolve([]),
@ -32,7 +32,7 @@ export function useScopeServicesState() {
},
};
}
const { updateNode, filterNode, selectScope, resetSelection, searchAllNodes, deselectScope, apply, getScopeNodes } =
const { filterNode, selectScope, resetSelection, searchAllNodes, deselectScope, apply, getScopeNodes } =
services.scopesSelectorService;
const selectorServiceState: ScopesSelectorServiceState | undefined = useObservable(
services.scopesSelectorService.stateObservable ?? new Observable(),
@ -42,7 +42,6 @@ export function useScopeServicesState() {
return {
getScopeNodes,
filterNode,
updateNode,
selectScope,
resetSelection,
searchAllNodes,

View file

@ -107,162 +107,9 @@ describe('ScopesSelectorService', () => {
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, '');
await service.filterNode('', '');
});
it('should select a scope', async () => {
@ -311,7 +158,7 @@ describe('ScopesSelectorService', () => {
it('should set parent node for recent scopes', async () => {
// Load mock node
await service.updateNode('', true, '');
await service.filterNode('', '');
await service.changeScopes(['test-scope'], 'test-scope-node');
expect(service.state.appliedScopes).toEqual([{ scopeId: 'test-scope', parentNodeId: 'test-scope-node' }]);
@ -363,7 +210,7 @@ describe('ScopesSelectorService', () => {
describe('closeAndApply', () => {
it('should close the selector and apply the selected scopes', async () => {
await service.updateNode('', true, '');
await service.filterNode('', '');
await service.selectScope('test-scope-node');
await service.closeAndApply();
expect(service.state.opened).toBe(false);
@ -373,6 +220,7 @@ describe('ScopesSelectorService', () => {
describe('apply', () => {
it('should apply the selected scopes without closing the selector', async () => {
await service.filterNode('', '');
await service.open();
await service.selectScope('test-scope-node');
await service.apply();
@ -391,7 +239,7 @@ describe('ScopesSelectorService', () => {
describe('removeAllScopes', () => {
it('should remove all selected and applied scopes', async () => {
await service.updateNode('', true, '');
await service.filterNode('', '');
await service.selectScope('test-scope-node');
await service.apply();
await service.removeAllScopes();
@ -399,7 +247,7 @@ describe('ScopesSelectorService', () => {
});
it('should clear navigation scope when removing all scopes', async () => {
await service.updateNode('', true, '');
await service.filterNode('', '');
await service.selectScope('test-scope-node');
await service.apply();
await service.removeAllScopes();
@ -429,7 +277,7 @@ describe('ScopesSelectorService', () => {
describe('getRecentScopes', () => {
it('should parse and filter scopes', async () => {
await service.updateNode('', true, '');
await service.filterNode('', '');
await service.selectScope('test-scope-node');
await service.apply();
storeValue[RECENT_SCOPES_KEY] = JSON.stringify([[mockScope2], [mockScope]]);
@ -439,7 +287,7 @@ describe('ScopesSelectorService', () => {
});
it('should work with old version', async () => {
await service.updateNode('', true, '');
await service.filterNode('', '');
await service.selectScope('test-scope-node');
await service.apply();
storeValue[RECENT_SCOPES_KEY] = JSON.stringify([
@ -615,9 +463,347 @@ describe('ScopesSelectorService', () => {
});
});
describe('toggleExpandedNode', () => {
const expandableNode: ScopeNode = {
metadata: { name: 'expandable-node' },
spec: {
linkId: '',
linkType: undefined,
parentName: '',
nodeType: 'container',
title: 'Expandable Node',
},
};
const childNode: ScopeNode = {
metadata: { name: 'child-node' },
spec: {
linkId: 'child-scope',
linkType: 'scope',
parentName: 'expandable-node',
nodeType: 'leaf',
title: 'Child Node',
},
};
const leafNode: ScopeNode = {
metadata: { name: 'leaf-node' },
spec: {
linkId: 'leaf-scope',
linkType: 'scope',
parentName: '',
nodeType: 'leaf',
title: 'Leaf Node',
},
};
beforeEach(async () => {
// Mock fetchNodes to return different nodes based on parent
apiClient.fetchNodes = jest
.fn()
.mockImplementation((options: { parent?: string; query?: string; limit?: number }) => {
if (options.parent === '') {
return [expandableNode, leafNode];
} else if (options.parent === 'expandable-node') {
return [childNode];
}
return [];
});
// Load root nodes
await service.filterNode('', '');
});
it('should expand a collapsed node and load its children', async () => {
// Node should start collapsed
expect(service.state.tree?.children?.['expandable-node']?.expanded).toBe(false);
// Expand the node
await service.toggleExpandedNode('expandable-node');
// Node should now be expanded
expect(service.state.tree?.children?.['expandable-node']?.expanded).toBe(true);
// Children should be loaded
expect(service.state.tree?.children?.['expandable-node']?.children).toBeDefined();
expect(service.state.tree?.children?.['expandable-node']?.children?.['child-node']).toBeDefined();
});
it('should collapse an expanded node', async () => {
// First expand the node
await service.toggleExpandedNode('expandable-node');
expect(service.state.tree?.children?.['expandable-node']?.expanded).toBe(true);
// Now collapse it
await service.toggleExpandedNode('expandable-node');
expect(service.state.tree?.children?.['expandable-node']?.expanded).toBe(false);
});
it('should reset query to empty string when toggling', async () => {
// First filter with a query
await service.filterNode('expandable-node', 'test-query');
expect(service.state.tree?.children?.['expandable-node']?.query).toBe('test-query');
// Toggle the node
await service.toggleExpandedNode('expandable-node');
// Query should be reset
expect(service.state.tree?.children?.['expandable-node']?.query).toBe('');
});
it('should throw error when node not found in tree', async () => {
await expect(service.toggleExpandedNode('non-existent-node')).rejects.toThrow(
'Node non-existent-node not found in tree'
);
});
it('should throw error when trying to toggle a non-expandable node', async () => {
await expect(service.toggleExpandedNode('leaf-node')).rejects.toThrow(
'Trying to expand node at id leaf-node that is not expandable'
);
});
it('should reload parent children when collapsing', async () => {
const fetchNodesSpy = jest.spyOn(apiClient, 'fetchNodes');
// Expand then collapse
await service.toggleExpandedNode('expandable-node');
fetchNodesSpy.mockClear();
await service.toggleExpandedNode('expandable-node');
// Should reload parent's (root) children
expect(fetchNodesSpy).toHaveBeenCalledWith({ parent: '', query: '' });
});
it('should reload parent children with parent query when collapsing', async () => {
// First filter the root with a query
await service.filterNode('', 'parent-query');
// Expand a node
await service.toggleExpandedNode('expandable-node');
const fetchNodesSpy = jest.spyOn(apiClient, 'fetchNodes');
// Collapse the node
await service.toggleExpandedNode('expandable-node');
// Should reload parent's children with parent's query
expect(fetchNodesSpy).toHaveBeenCalledWith({ parent: '', query: 'parent-query' });
});
});
describe('filterNode', () => {
const containerNode: ScopeNode = {
metadata: { name: 'container-node' },
spec: {
linkId: '',
linkType: undefined,
parentName: '',
nodeType: 'container',
title: 'Container Node',
},
};
const filteredChild: ScopeNode = {
metadata: { name: 'filtered-child' },
spec: {
linkId: 'filtered-scope',
linkType: 'scope',
parentName: 'container-node',
nodeType: 'leaf',
title: 'Filtered Child',
},
};
const leafNode: ScopeNode = {
metadata: { name: 'leaf-node-2' },
spec: {
linkId: 'leaf-scope-2',
linkType: 'scope',
parentName: '',
nodeType: 'leaf',
title: 'Leaf Node 2',
},
};
beforeEach(async () => {
// Mock fetchNodes to return different nodes based on query
apiClient.fetchNodes = jest
.fn()
.mockImplementation((options: { parent?: string; query?: string; limit?: number }) => {
if (options.parent === '' && !options.query) {
return [containerNode, leafNode];
} else if (options.parent === 'container-node' && options.query === 'test-filter') {
return [filteredChild];
} else if (options.parent === 'container-node' && !options.query) {
return [filteredChild];
}
return [];
});
// Load root nodes
await service.filterNode('', '');
});
it('should filter node with non-empty query', async () => {
await service.filterNode('container-node', 'test-filter');
// Node should be expanded
expect(service.state.tree?.children?.['container-node']?.expanded).toBe(true);
// Query should be set
expect(service.state.tree?.children?.['container-node']?.query).toBe('test-filter');
});
it('should load children with the query parameter', async () => {
const fetchNodesSpy = jest.spyOn(apiClient, 'fetchNodes');
await service.filterNode('container-node', 'my-query');
expect(fetchNodesSpy).toHaveBeenCalledWith({ parent: 'container-node', query: 'my-query' });
});
it('should set expanded to true when filtering', async () => {
// Node starts collapsed
expect(service.state.tree?.children?.['container-node']?.expanded).toBe(false);
await service.filterNode('container-node', 'test-filter');
// Should be expanded after filtering
expect(service.state.tree?.children?.['container-node']?.expanded).toBe(true);
});
it('should throw error when node not found', async () => {
await expect(service.filterNode('non-existent-node', 'query')).rejects.toThrow(
'Trying to filter node at path or id non-existent-node not found'
);
});
it('should throw error when trying to filter a non-expandable node', async () => {
await expect(service.filterNode('leaf-node-2', 'query')).rejects.toThrow(
'Trying to filter node at id leaf-node-2 that is not expandable'
);
});
it('should handle multiple calls with different queries', async () => {
// First filter
await service.filterNode('container-node', 'first-query');
expect(service.state.tree?.children?.['container-node']?.query).toBe('first-query');
// Second filter with different query
await service.filterNode('container-node', 'second-query');
expect(service.state.tree?.children?.['container-node']?.query).toBe('second-query');
// Third filter with empty query
await service.filterNode('container-node', '');
expect(service.state.tree?.children?.['container-node']?.query).toBe('');
});
it('should start profiler interaction', async () => {
const profiler = {
startInteraction: jest.fn(),
stopInteraction: jest.fn(),
};
// Create new service with profiler
const serviceWithProfiler = new ScopesSelectorService(apiClient, dashboardsService, store, profiler as never);
await serviceWithProfiler.filterNode('', '');
expect(profiler.startInteraction).toHaveBeenCalledWith('scopeNodeFilter');
expect(profiler.stopInteraction).toHaveBeenCalled();
});
it('should stop profiler even when error is thrown', async () => {
const profiler = {
startInteraction: jest.fn(),
stopInteraction: jest.fn(),
};
const serviceWithProfiler = new ScopesSelectorService(apiClient, dashboardsService, store, profiler as never);
// Load initial nodes
await serviceWithProfiler.filterNode('', '');
// Try to filter a non-existent node
await expect(serviceWithProfiler.filterNode('non-existent', 'query')).rejects.toThrow();
// Profiler should still be stopped
expect(profiler.stopInteraction).toHaveBeenCalled();
});
});
describe('interaction between toggleExpandedNode and filterNode', () => {
const expandableNode: ScopeNode = {
metadata: { name: 'interaction-node' },
spec: {
linkId: '',
linkType: undefined,
parentName: '',
nodeType: 'container',
title: 'Interaction Node',
},
};
const childNode: ScopeNode = {
metadata: { name: 'interaction-child' },
spec: {
linkId: 'child-scope',
linkType: 'scope',
parentName: 'interaction-node',
nodeType: 'leaf',
title: 'Child Node',
},
};
beforeEach(async () => {
apiClient.fetchNodes = jest
.fn()
.mockImplementation((options: { parent?: string; query?: string; limit?: number }) => {
if (options.parent === '') {
return [expandableNode];
} else if (options.parent === 'interaction-node') {
return [childNode];
}
return [];
});
await service.filterNode('', '');
});
it('should clear query when toggleExpandedNode is called after filterNode', async () => {
// Filter with a query
await service.filterNode('interaction-node', 'test-query');
expect(service.state.tree?.children?.['interaction-node']?.query).toBe('test-query');
// Toggle should clear the query
await service.toggleExpandedNode('interaction-node');
expect(service.state.tree?.children?.['interaction-node']?.query).toBe('');
});
it('should set query when filterNode is called after toggleExpandedNode', async () => {
// First toggle (expand)
await service.toggleExpandedNode('interaction-node');
expect(service.state.tree?.children?.['interaction-node']?.query).toBe('');
// Filter should set the query
await service.filterNode('interaction-node', 'new-query');
expect(service.state.tree?.children?.['interaction-node']?.query).toBe('new-query');
});
it('should maintain expanded state when filtering an already expanded node', async () => {
// Expand the node
await service.toggleExpandedNode('interaction-node');
expect(service.state.tree?.children?.['interaction-node']?.expanded).toBe(true);
// Filter should keep it expanded
await service.filterNode('interaction-node', 'query');
expect(service.state.tree?.children?.['interaction-node']?.expanded).toBe(true);
});
});
describe('redirect on scope selection', () => {
it('should redirect to the first scopeNavigation with /d/ URL when current URL is not a scopeNavigation', async () => {
const mockNavigations: ScopeNavigation[] = [
dashboardsService.state.scopeNavigations = [
{
spec: {
scope: 'test-scope',
@ -632,8 +818,6 @@ describe('ScopesSelectorService', () => {
},
},
];
dashboardsService.state.scopeNavigations = mockNavigations;
(locationService.getLocation as jest.Mock).mockReturnValue({ pathname: '/some-other-page' });
await service.changeScopes(['test-scope']);
@ -642,7 +826,7 @@ describe('ScopesSelectorService', () => {
});
it('should NOT redirect when the first scopeNavigation does not contain /d/ (e.g., logs drilldown)', async () => {
const mockNavigations: ScopeNavigation[] = [
dashboardsService.state.scopeNavigations = [
{
spec: {
scope: 'test-scope',
@ -657,8 +841,6 @@ describe('ScopesSelectorService', () => {
},
},
];
dashboardsService.state.scopeNavigations = mockNavigations;
(locationService.getLocation as jest.Mock).mockReturnValue({ pathname: '/some-other-page' });
await service.changeScopes(['test-scope']);
@ -667,7 +849,7 @@ describe('ScopesSelectorService', () => {
});
it('should NOT redirect when current URL matches a scopeNavigation', async () => {
const mockNavigations: ScopeNavigation[] = [
dashboardsService.state.scopeNavigations = [
{
spec: {
scope: 'test-scope',
@ -682,8 +864,6 @@ describe('ScopesSelectorService', () => {
},
},
];
dashboardsService.state.scopeNavigations = mockNavigations;
(locationService.getLocation as jest.Mock).mockReturnValue({ pathname: '/d/dashboard1' });
await service.changeScopes(['test-scope']);
@ -701,7 +881,7 @@ describe('ScopesSelectorService', () => {
});
it('should NOT redirect when scopeNavigation does not have a url property', async () => {
const mockNavigations = [
dashboardsService.state.scopeNavigations = [
{
spec: {
scope: 'test-scope',
@ -716,8 +896,6 @@ describe('ScopesSelectorService', () => {
},
},
] as unknown as ScopeNavigation[];
dashboardsService.state.scopeNavigations = mockNavigations;
(locationService.getLocation as jest.Mock).mockReturnValue({ pathname: '/some-other-page' });
await service.changeScopes(['test-scope']);
@ -726,7 +904,7 @@ describe('ScopesSelectorService', () => {
});
it('should handle multiple scopeNavigations and redirect to the first dashboard one', async () => {
const mockNavigations: ScopeNavigation[] = [
dashboardsService.state.scopeNavigations = [
{
spec: {
scope: 'test-scope',
@ -754,8 +932,6 @@ describe('ScopesSelectorService', () => {
},
},
];
dashboardsService.state.scopeNavigations = mockNavigations;
(locationService.getLocation as jest.Mock).mockReturnValue({ pathname: '/some-other-page' });
await service.changeScopes(['test-scope']);
@ -790,7 +966,7 @@ describe('ScopesSelectorService', () => {
});
// First update the node to populate the service state
await service.updateNode('', true, '');
await service.filterNode('', '');
// Then select the scope to set scopeNodeId in selectedScopes
await service.selectScope('test-scope-node');
@ -837,7 +1013,7 @@ describe('ScopesSelectorService', () => {
(locationService.getLocation as jest.Mock).mockReturnValue({ pathname: '/some-other-page' });
// First update the node to populate the service state
await service.updateNode('', true, '');
await service.filterNode('', '');
// Then select the scope to set scopeNodeId in selectedScopes
await service.selectScope('test-scope-node');
@ -851,16 +1027,14 @@ describe('ScopesSelectorService', () => {
});
it('should fall back to scope navigation when scope node is undefined', async () => {
const mockNavigations: ScopeNavigation[] = [
// Don't add the node to the service state, so it will be undefined
dashboardsService.state.scopeNavigations = [
{
spec: { scope: 'test-scope', url: '/d/dashboard1' },
status: { title: 'Dashboard 1', groups: [] },
metadata: { name: 'dashboard1' },
},
];
// Don't add the node to the service state, so it will be undefined
dashboardsService.state.scopeNavigations = mockNavigations;
(locationService.getLocation as jest.Mock).mockReturnValue({ pathname: '/some-other-page' });
await service.changeScopes(['test-scope']);

View file

@ -129,81 +129,38 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
// Resets query and toggles expanded state of a node
public toggleExpandedNode = async (scopeNodeId: string) => {
const path = getPathOfNode(scopeNodeId, this.state.nodes);
const nodeToToggle = treeNodeAtPath(this.state.tree!, path);
if (!nodeToToggle) {
throw new Error(`Node ${scopeNodeId} not found in tree`);
}
if (nodeToToggle.scopeNodeId !== '' && !isNodeExpandable(this.state.nodes[nodeToToggle.scopeNodeId])) {
throw new Error(`Trying to expand node at id ${scopeNodeId} that is not expandable`);
}
const newTree = modifyTreeNodeAtPath(this.state.tree!, path, (treeNode) => {
treeNode.expanded = !nodeToToggle.expanded;
treeNode.query = '';
});
this.updateState({ tree: newTree });
// If we are collapsing, we need to make sure that all the parent's children are avilable
if (nodeToToggle.expanded === true) {
const parentPath = path.slice(0, -1);
const parentNode = treeNodeAtPath(this.state.tree!, parentPath);
if (parentNode) {
await this.loadNodeChildren(parentPath, parentNode, parentNode.query);
}
} else {
await this.loadNodeChildren(path, nodeToToggle);
}
};
public filterNode = async (scopeNodeId: string, query: string) => {
const path = getPathOfNode(scopeNodeId, this.state.nodes);
const nodeToFilter = treeNodeAtPath(this.state.tree!, path);
if (!nodeToFilter) {
throw new Error(`Trying to filter node at path or id ${scopeNodeId} not found`);
}
if (nodeToFilter.scopeNodeId !== '' && !isNodeExpandable(this.state.nodes[nodeToFilter.scopeNodeId])) {
throw new Error(`Trying to filter node at id ${scopeNodeId} that is not expandable`);
}
const newTree = modifyTreeNodeAtPath(this.state.tree!, path, (treeNode) => {
treeNode.expanded = true;
treeNode.query = query;
});
this.updateState({ tree: newTree });
await this.loadNodeChildren(path, nodeToFilter, query);
};
private expandOrFilterNode = async (scopeNodeId: string, query?: string) => {
this.interactionProfiler?.startInteraction('scopeNodeDiscovery');
const path = getPathOfNode(scopeNodeId, this.state.nodes);
const nodeToExpand = treeNodeAtPath(this.state.tree!, path);
this.interactionProfiler?.startInteraction('scopeToggleExpandedNode');
try {
if (!nodeToExpand) {
const path = getPathOfNode(scopeNodeId, this.state.nodes);
const nodeToToggle = treeNodeAtPath(this.state.tree!, path);
if (!nodeToToggle) {
throw new Error(`Node ${scopeNodeId} not found in tree`);
}
if (nodeToExpand.scopeNodeId !== '' && !isNodeExpandable(this.state.nodes[nodeToExpand.scopeNodeId])) {
if (nodeToToggle.scopeNodeId !== '' && !isNodeExpandable(this.state.nodes[nodeToToggle.scopeNodeId])) {
throw new Error(`Trying to expand node at id ${scopeNodeId} that is not expandable`);
}
if (!nodeToExpand.expanded || nodeToExpand.query !== query) {
const newTree = modifyTreeNodeAtPath(this.state.tree!, path, (treeNode) => {
treeNode.expanded = true;
treeNode.query = query || '';
});
this.updateState({ tree: newTree });
const newTree = modifyTreeNodeAtPath(this.state.tree!, path, (treeNode) => {
treeNode.expanded = !nodeToToggle.expanded;
treeNode.query = '';
});
await this.loadNodeChildren(path, nodeToExpand, query);
this.updateState({ tree: newTree });
// If we are collapsing, we need to make sure that all the parent's children are available
if (nodeToToggle.expanded) {
const parentPath = path.slice(0, -1);
const parentNode = treeNodeAtPath(this.state.tree!, parentPath);
if (parentNode) {
await this.loadNodeChildren(parentPath, parentNode, parentNode.query);
}
} else {
await this.loadNodeChildren(path, nodeToToggle);
}
// Catch and throw error so we can ensure the profiler is stopped
// todo: leverage component-level
} catch (error) {
throw error;
} finally {
@ -211,20 +168,34 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
}
};
private collapseNode = async (scopeNodeId: string) => {
const path = getPathOfNode(scopeNodeId, this.state.nodes);
public filterNode = async (scopeNodeId: string, query: string) => {
this.interactionProfiler?.startInteraction('scopeNodeFilter');
const nodeToCollapse = treeNodeAtPath(this.state.tree!, path);
try {
const path = getPathOfNode(scopeNodeId, this.state.nodes);
const nodeToFilter = treeNodeAtPath(this.state.tree!, path);
if (!nodeToCollapse) {
throw new Error(`Trying to collapse node at path or id ${scopeNodeId} not found`);
if (!nodeToFilter) {
throw new Error(`Trying to filter node at path or id ${scopeNodeId} not found`);
}
if (nodeToFilter.scopeNodeId !== '' && !isNodeExpandable(this.state.nodes[nodeToFilter.scopeNodeId])) {
throw new Error(`Trying to filter node at id ${scopeNodeId} that is not expandable`);
}
const newTree = modifyTreeNodeAtPath(this.state.tree!, path, (treeNode) => {
treeNode.expanded = true;
treeNode.query = query;
});
this.updateState({ tree: newTree });
await this.loadNodeChildren(path, nodeToFilter, query);
// Catch and throw error so we can ensure the profiler is stopped
} catch (error) {
throw error;
} finally {
this.interactionProfiler?.stopInteraction();
}
const newTree = modifyTreeNodeAtPath(this.state.tree!, path, (treeNode) => {
treeNode.expanded = false;
treeNode.query = '';
});
this.updateState({ tree: newTree });
};
private loadNodeChildren = async (path: string[], treeNode: TreeNode, query?: string) => {
@ -332,15 +303,6 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
this.updateState({ selectedScopes: newSelectedScopes });
};
// TODO: Replace all usage of this function with expandNode and filterNode.
// @deprecated
public updateNode = async (scopeNodeId: string, expanded: boolean, query: string) => {
if (expanded) {
return this.expandOrFilterNode(scopeNodeId, query);
}
return this.collapseNode(scopeNodeId);
};
changeScopes = (scopeNames: string[], parentNodeId?: string, scopeNodeId?: string, redirectOnApply?: boolean) => {
return this.applyScopes(
scopeNames.map((id, index) => ({
@ -494,7 +456,7 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
*/
public open = async () => {
if (!this.state.tree?.children || Object.keys(this.state.tree?.children).length === 0) {
await this.expandOrFilterNode('');
await this.filterNode('', '');
}
// First close all nodes