Scopes: Nested scope navigations (#113394)

* Initial nested scope navigation

* Seperate sections for scope navigation vs groups

* Add ungrouped navigation items

* Create tree structure helper for mock scope navigations

* Improve generated folder structure and add link to change scope

* Update mocks

* DevEnv Navigations

* Remove mocks

* Update button position and add border

* Remove subScope from TreeLink

* Do unblocking async update

* Add loading state

* Allow '' for root groups

* Formatting

* Add unit and integration tests

* Update openapi spec

* Update tooltip for IconButton

* Update i18n

* Make code more DRY

* Update public/app/dev-utils.ts

* update folderUpdate type

* Remove isSubScope in facvor of checking subScopeName

* Do an early return

* Use subScopeName

* Remove use of isSubScope

* Prevent nested items with the same subScope

* Extract filder method to function

---------

Co-authored-by: Mariell Hoversholm <mariell@mardroemmar.dev>
This commit is contained in:
Tobias Skarhed
2025-11-17 17:18:12 +01:00
committed by GitHub
co-authored by Mariell Hoversholm
parent b6d34acc44
commit b79ace082c
12 changed files with 1357 additions and 89 deletions
@@ -209,6 +209,8 @@ type FindScopeNavigationsResults struct {
type ScopeNavigationSpec struct {
URL string `json:"url"`
Scope string `json:"scope"`
// Used to navigate to a sub-scope of the main scope. URL will not be used if this is set.
SubScope string `json:"subScope,omitempty"`
}
// Type of the item.
@@ -635,6 +635,13 @@ func schema_pkg_apis_scope_v0alpha1_ScopeNavigationSpec(ref common.ReferenceCall
Format: "",
},
},
"subScope": {
SchemaProps: spec.SchemaProps{
Description: "Used to navigate to a sub-scope of the main scope. URL will not be used if this is set.",
Type: []string{"string"},
Format: "",
},
},
},
Required: []string{"url", "scope"},
},
+175 -27
View File
@@ -1,29 +1,58 @@
scopes:
app1:
title: Application 1
shoe-org:
title: Shoe organization
filters:
- key: organization
operator: equals
value: shoe
shoes:
title: Shoes
filters:
- key: product
operator: equals
value: shoes
apparel:
title: Apparel
filters:
- key: product
operator: equals
value: apparel
frontend:
title: Frontend
filters:
- key: team
operator: equals
value: frontend
database:
title: Database
filters:
- key: team
operator: equals
value: database
main-app:
title: Main App
filters:
- key: app
operator: equals
value: app1
value: main
app2:
title: Application 2
kids-app:
title: Kids App
filters:
- key: app
operator: equals
value: app2
cluster1:
title: Cluster 1
filters:
- key: cluster
operator: equals
value: cluster1
value: kids
tree:
gdev-scopes:
title: gdev-scopes
nodeType: container
disableMultiSelect: true
children:
production:
title: Production
@@ -54,7 +83,6 @@ tree:
nodeType: leaf
linkId: test-case-2
linkType: scope
clusters:
title: Clusters
nodeType: container
@@ -66,19 +94,139 @@ tree:
nodeType: leaf
linkId: cluster1
linkType: scope
shoe-org-root:
title: Shoe organization
subTitle: Nested scopes navigation
nodeType: leaf
linkId: shoe-org
linkType: scope
navigations:
# Example: Link to a dashboard
app1-nav:
url: /d/86Js1xRmk
scope: app1
navigationTree:
- name: global-overview
title: Global overview
url: /d/TkZXxlNG3
scope: shoe-org
# Example: Link to a dashboard with full URL (already has /d/)
app2-nav:
url: /d/GlAqcPgmz
scope: app2
- name: reliability-placeholder
title: Reliability
url: /d/dcb9f5e9-8066-4397-889e-864b99555dbb
scope: shoe-org
groups:
- Reliability
# Example: Custom URL path
custom-nav:
url: /explore
scope: app1
- name: shoes
title: Shoes
url: /d/_5rDmaQiz
scope: shoe-org
subScope: shoes
children:
- name: shoes-overview
title: Overview
url: /d/5SdHCadmz
scope: shoes
- name: shoes-team-overview
title: Team Overview
url: /d/EJ8_d9jZk
scope: shoes
- name: shoes-frontend
title: Frontend
url: /d/edediimbjhdz4b
scope: shoes
subScope: frontend
children:
- name: frontend-api
title: API Metrics
url: /d/5Y0jv6pVz
scope: frontend
- name: frontend-ui
title: UI Performance
url: /d/2xuwrgV7z
scope: frontend
- name: frontend-shoes
title: Nested shoes
url: /d/5SdHCadmz
scope: frontend
subScope: shoes # This should be filtered out since 'shoes' is already in the path
- name: shoes-database
title: Database
url: /d/lVE-2YFMz
scope: shoes
subScope: database
children:
- name: database-connections
title: Database Connections
url: /d/mIJjFy8Kz
scope: database
- name: database-replication
title: Database Replication
url: /d/fdn48fmz8f94wc
scope: database
- name: shoes-main-app
title: Main App
url: /d/WZ7AhQiVz
scope: shoes
subScope: main-app
children:
- name: main-app-users
title: User Analytics
url: /d/a6801696-cc53-4196-b1f9-2403e3909185
scope: main-app
- name: main-app-revenue
title: Revenue Metrics
url: /d/imQX6j-Gz
scope: main-app
- name: shoes-kids-app
title: Kids App
url: /d/bds35fot3cv7kb
scope: shoes
subScope: kids-app
children:
- name: kids-app-features
title: Feature Usage
url: /d/16f11TZWk
scope: kids-app
- name: kids-app-engagement
title: User Engagement
url: /d/c01bf42b-b783-4447-a304-8554cee1843b
scope: kids-app
- name: apparel
title: Apparel
url: /d/UTv--wqMk
scope: shoe-org
subScope: apparel
children:
- name: apparel-product-overview
title: Product Overview
url: /d/Kp9Z0hTik
scope: apparel
- name: all-teams-placeholder
title: All Teams
url: /d/b36b5576-2e3d-4b0c-8dce-e79514d99345
scope: shoe-org
groups:
- Discovered dashboards
children:
- name: others-placeholder
title: Others
url: /d/xtY_uCAiz
scope: shoe-org
groups:
- All Teams
children:
- name: latency-and-errors
title: Latency and Errors
url: /d/ZqZnVvFZz
scope: shoe-org
groups:
- Others
+196 -13
View File
@@ -43,9 +43,10 @@ func getEnv(key, defaultValue string) string {
}
type Config struct {
Scopes map[string]ScopeConfig `yaml:"scopes"`
Tree map[string]TreeNode `yaml:"tree"`
Navigations map[string]NavigationConfig `yaml:"navigations"`
Scopes map[string]ScopeConfig `yaml:"scopes"`
Tree map[string]TreeNode `yaml:"tree"`
Navigations map[string]NavigationConfig `yaml:"navigations"`
NavigationTree []NavigationTreeNode `yaml:"navigationTree"`
}
// ScopeConfig is used for YAML parsing - converts to v0alpha1.ScopeSpec
@@ -64,16 +65,32 @@ type ScopeFilterConfig struct {
// TreeNode is used for YAML parsing - converts to v0alpha1.ScopeNodeSpec
type TreeNode struct {
Title string `yaml:"title"`
NodeType string `yaml:"nodeType"`
LinkID string `yaml:"linkId,omitempty"`
LinkType string `yaml:"linkType,omitempty"`
Children map[string]TreeNode `yaml:"children,omitempty"`
Title string `yaml:"title"`
SubTitle string `yaml:"subTitle,omitempty"`
NodeType string `yaml:"nodeType"`
LinkID string `yaml:"linkId,omitempty"`
LinkType string `yaml:"linkType,omitempty"`
DisableMultiSelect bool `yaml:"disableMultiSelect,omitempty"`
Children map[string]TreeNode `yaml:"children,omitempty"`
}
type NavigationConfig struct {
URL string `yaml:"url"` // URL path (e.g., /d/abc123 or /explore)
Scope string `yaml:"scope"`
URL string `yaml:"url"` // URL path (e.g., /d/abc123 or /explore)
Scope string `yaml:"scope"` // Required scope
SubScope string `yaml:"subScope"` // Optional subScope for hierarchical navigation
Title string `yaml:"title"` // Display title
Groups []string `yaml:"groups"` // Optional groups for categorization
}
// NavigationTreeNode represents a node in the navigation tree structure
type NavigationTreeNode struct {
Name string `yaml:"name"`
Title string `yaml:"title"`
URL string `yaml:"url"`
Scope string `yaml:"scope"`
SubScope string `yaml:"subScope,omitempty"`
Groups []string `yaml:"groups,omitempty"`
Children []NavigationTreeNode `yaml:"children,omitempty"`
}
// Helper function to convert ScopeFilterConfig to v0alpha1.ScopeFilter
@@ -156,6 +173,41 @@ func (c *Client) makeRequest(method, endpoint string, body []byte) error {
return nil
}
func (c *Client) getScopeNavigation(name string) (*v0alpha1.ScopeNavigation, error) {
url := fmt.Sprintf("%s/apis/%s/namespaces/%s/scopenavigations/%s", c.baseURL, apiVersion, c.namespace, name)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.SetBasicAuth(strings.Split(c.auth, ":")[0], strings.Split(c.auth, ":")[1])
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
bodyBytes, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("API request failed: HTTP %d - %s", resp.StatusCode, string(bodyBytes))
}
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
var navigation v0alpha1.ScopeNavigation
if err := json.Unmarshal(bodyBytes, &navigation); err != nil {
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
}
return &navigation, nil
}
func (c *Client) createScope(name string, cfg ScopeConfig) error {
prefixedName := prefix + "-" + name
@@ -206,8 +258,9 @@ func (c *Client) createScopeNode(name string, node TreeNode, parentName string)
spec := v0alpha1.ScopeNodeSpec{
Title: node.Title,
SubTitle: node.SubTitle,
NodeType: nodeType,
DisableMultiSelect: false,
DisableMultiSelect: node.DisableMultiSelect,
}
if prefixedParent != "" {
@@ -241,17 +294,34 @@ func (c *Client) createScopeNode(name string, node TreeNode, parentName string)
func (c *Client) createScopeNavigation(name string, nav NavigationConfig) error {
prefixedName := prefix + "-" + name
prefixedScope := prefix + "-" + nav.Scope
if nav.URL == "" {
return fmt.Errorf("navigation %s must have 'url' specified", name)
}
if nav.Scope == "" {
return fmt.Errorf("navigation %s must have 'scope' specified", name)
}
prefixedScope := prefix + "-" + nav.Scope
spec := v0alpha1.ScopeNavigationSpec{
URL: nav.URL,
Scope: prefixedScope,
}
if nav.SubScope != "" {
prefixedSubScope := prefix + "-" + nav.SubScope
spec.SubScope = prefixedSubScope
}
status := v0alpha1.ScopeNavigationStatus{
Title: nav.Title,
}
if len(nav.Groups) > 0 {
status.Groups = nav.Groups
}
resource := v0alpha1.ScopeNavigation{
TypeMeta: metav1.TypeMeta{
APIVersion: apiVersion,
@@ -269,7 +339,99 @@ func (c *Client) createScopeNavigation(name string, nav NavigationConfig) error
}
fmt.Printf("✓ Creating scope navigation: %s\n", prefixedName)
return c.makeRequest("POST", "/scopenavigations", body)
if err := c.makeRequest("POST", "/scopenavigations", body); err != nil {
return err
}
// Update status in a second request (status is a subresource)
if nav.Title != "" || len(nav.Groups) > 0 {
// Get the created resource to retrieve its resourceVersion and existing spec
createdNav, err := c.getScopeNavigation(prefixedName)
if err != nil {
return fmt.Errorf("failed to get created navigation: %w", err)
}
statusResource := v0alpha1.ScopeNavigation{
TypeMeta: metav1.TypeMeta{
APIVersion: apiVersion,
Kind: "ScopeNavigation",
},
ObjectMeta: metav1.ObjectMeta{
Name: prefixedName,
ResourceVersion: createdNav.ObjectMeta.ResourceVersion,
},
Spec: createdNav.Spec, // Include existing spec to prevent it from being cleared
Status: status,
}
statusBody, err := json.Marshal(statusResource)
if err != nil {
return fmt.Errorf("failed to marshal scope navigation status: %w", err)
}
fmt.Printf(" Updating status for: %s\n", prefixedName)
return c.makeRequest("PUT", fmt.Sprintf("/scopenavigations/%s/status", prefixedName), statusBody)
}
return nil
}
// NavigationWithName pairs a navigation config with its name and title
type NavigationWithName struct {
Name string
Title string
Nav NavigationConfig
}
// Convert navigation tree to flat navigations (similar to mock's treeToNavigations)
func treeToNavigations(node NavigationTreeNode, parentPath []string, dashboardCounter *int) []NavigationWithName {
navigations := []NavigationWithName{}
currentPath := append(parentPath, node.Name)
// Generate URL if not provided (cycle through dash-1, dash-2, etc.)
url := node.URL
if url == "" {
*dashboardCounter++
url = fmt.Sprintf("/d/dash-%d", *dashboardCounter)
}
// Create navigation for this node
nav := NavigationConfig{
URL: url,
Scope: node.Scope,
Title: node.Title,
}
if node.SubScope != "" {
nav.SubScope = node.SubScope
}
if len(node.Groups) > 0 {
nav.Groups = node.Groups
}
navigations = append(navigations, NavigationWithName{
Name: node.Name,
Title: node.Title,
Nav: nav,
})
// Process children - they inherit the parent's subScope as their scope, or use parent's scope if no subScope
if len(node.Children) > 0 {
for _, child := range node.Children {
// Children inherit the parent's subScope as their scope, or use parent's scope if no subScope
childScope := node.SubScope
if childScope == "" {
childScope = node.Scope
}
// Override with child's scope if explicitly set
if child.Scope != "" {
childScope = child.Scope
} else {
child.Scope = childScope
}
navigations = append(navigations, treeToNavigations(child, currentPath, dashboardCounter)...)
}
}
return navigations
}
func (c *Client) createTreeNodes(children map[string]TreeNode, parentName string) error {
@@ -418,6 +580,27 @@ func main() {
}
// Create scope navigations
// First, process navigation tree if provided
if len(config.NavigationTree) > 0 {
fmt.Println("Creating scope navigations from tree...")
dashboardCounter := 0
for _, rootNode := range config.NavigationTree {
flatNavigations := treeToNavigations(rootNode, []string{}, &dashboardCounter)
for _, navWithName := range flatNavigations {
// Use the title from navWithName if Nav.Title is empty
if navWithName.Nav.Title == "" && navWithName.Title != "" {
navWithName.Nav.Title = navWithName.Title
}
if err := client.createScopeNavigation(navWithName.Name, navWithName.Nav); err != nil {
fmt.Fprintf(os.Stderr, "Error creating scope navigation %s: %v\n", navWithName.Name, err)
os.Exit(1)
}
}
}
fmt.Println()
}
// Also support flat navigations format for backward compatibility
if len(config.Navigations) > 0 {
fmt.Println("Creating scope navigations...")
for name, nav := range config.Navigations {
@@ -5,8 +5,10 @@ import { ScopeDashboardBinding } from '@grafana/data';
import { config, locationService } from '@grafana/runtime';
import { ScopesApiClient } from '../ScopesApiClient';
// Import mock data for subScope tests
import { navigationWithSubScope, navigationWithSubScope2, navigationWithSubScopeAndGroups } from '../tests/utils/mocks';
import { ScopesDashboardsService } from './ScopesDashboardsService';
import { ScopesDashboardsService, filterItemsWithSubScopesInPath } from './ScopesDashboardsService';
import { ScopeNavigation } from './types';
jest.mock('@grafana/runtime', () => ({
@@ -15,6 +17,7 @@ jest.mock('@grafana/runtime', () => ({
featureToggles: {
useScopesNavigationEndpoint: false,
},
apps: {},
},
locationService: {
getLocation: jest.fn(),
@@ -390,4 +393,308 @@ describe('ScopesDashboardsService', () => {
});
});
});
describe('groupSuggestedItems with subScopes', () => {
it('Creates subScope folders for items with subScope', () => {
const result = service.groupSuggestedItems([navigationWithSubScope]);
expect(result[''].folders).toHaveProperty('mimir-subscope-nav-1');
expect(result[''].folders['mimir-subscope-nav-1']).toEqual({
title: 'Mimir Dashboards',
expanded: false,
folders: {},
suggestedNavigations: {},
subScopeName: 'mimir',
});
});
it('Creates separate folders for multiple items with same subScope', () => {
const result = service.groupSuggestedItems([navigationWithSubScope, navigationWithSubScope2]);
// Should create separate folders
expect(result[''].folders).toHaveProperty('mimir-subscope-nav-1');
expect(result[''].folders).toHaveProperty('mimir-subscope-nav-2');
// Both should reference the same subScope
expect(result[''].folders['mimir-subscope-nav-1'].subScopeName).toBe('mimir');
expect(result[''].folders['mimir-subscope-nav-2'].subScopeName).toBe('mimir');
});
it('Ignores groups for subScope items', () => {
const result = service.groupSuggestedItems([navigationWithSubScopeAndGroups]);
// Should create folder, not add to group folders
expect(result[''].folders).toHaveProperty('mimir-subscope-nav-groups');
expect(result[''].folders['mimir-subscope-nav-groups'].subScopeName).toBe('mimir');
// Should not add to any group folders
expect(Object.keys(result[''].folders).length).toBe(1);
expect(result[''].suggestedNavigations).toEqual({});
});
it('Does not add navigation items for subScope entries', () => {
const result = service.groupSuggestedItems([navigationWithSubScope]);
// Should only create folder, not add navigation item
expect(result[''].folders['mimir-subscope-nav-1'].suggestedNavigations).toEqual({});
expect(result[''].suggestedNavigations).toEqual({});
});
it('Mixes subScope and regular items correctly', () => {
const regularItem: ScopeNavigation = {
metadata: { name: 'regular-nav' },
spec: {
scope: 'grafana',
url: '/d/regular-dashboard',
},
status: {
title: 'Regular Dashboard',
groups: ['General'],
},
};
const result = service.groupSuggestedItems([navigationWithSubScope, regularItem]);
// Should have subScope folder
expect(result[''].folders).toHaveProperty('mimir-subscope-nav-1');
expect(result[''].folders['mimir-subscope-nav-1'].subScopeName).toBe('mimir');
// Should have regular group folder
expect(result[''].folders).toHaveProperty('General');
expect(result[''].folders['General'].subScopeName).toBeUndefined();
// Regular item should be in group folder
expect(result[''].folders['General'].suggestedNavigations).toHaveProperty('/d/regular-dashboard');
});
});
describe('fetchSubScopeItems infinite loop prevention', () => {
beforeEach(() => {
config.featureToggles.useScopesNavigationEndpoint = true;
});
afterEach(() => {
config.featureToggles.useScopesNavigationEndpoint = false;
});
it('should filter out items with subScope matching any subScope in the path', async () => {
// Mock current location
(locationService.getLocation as jest.Mock).mockReturnValue({ pathname: '/' } as Location);
// Create initial navigation with subScope 'mimir'
const initialNavigation: ScopeNavigation = {
metadata: { name: 'subscope-nav-1' },
spec: {
scope: 'grafana',
subScope: 'mimir',
url: '/d/mimir-dashboards',
},
status: {
title: 'Mimir Dashboards',
},
};
// Mock items returned when fetching 'mimir' subScope
// One of them has the same subScope 'mimir', which should be filtered out
const subScopeItemsWithSameSubScope: ScopeNavigation[] = [
{
metadata: { name: 'mimir-item-1' },
spec: {
scope: 'mimir',
url: '/d/mimir-dashboard-1',
},
status: {
title: 'Mimir Dashboard 1',
groups: ['General'],
},
},
{
metadata: { name: 'mimir-item-2' },
spec: {
scope: 'mimir',
subScope: 'mimir', // This should be filtered out - same subScope as in path
url: '/d/mimir-dashboard-2',
},
status: {
title: 'Mimir Dashboard 2',
},
},
{
metadata: { name: 'mimir-item-3' },
spec: {
scope: 'mimir',
url: '/d/mimir-dashboard-3',
},
status: {
title: 'Mimir Dashboard 3',
groups: ['Observability'],
},
},
];
// Set up mock to return items based on scope being fetched
mockApiClient.fetchScopeNavigations.mockImplementation((scopeNames: string[]) => {
if (scopeNames.includes('grafana')) {
return Promise.resolve([initialNavigation]);
}
if (scopeNames.includes('mimir')) {
return Promise.resolve(subScopeItemsWithSameSubScope);
}
return Promise.resolve([]);
});
// Initial fetch to create the subScope folder
await service.fetchDashboards(['grafana']);
// The folder key is based on the pattern: ${subScope}-${metadata.name}
const subScopeFolderKey = 'mimir-subscope-nav-1';
// Test the filtering logic directly
const filteredItems = filterItemsWithSubScopesInPath(
subScopeItemsWithSameSubScope,
['', subScopeFolderKey],
'mimir',
service.state.folders
);
// Verify that the item with the same subScope was filtered out
const hasFilteredItem = filteredItems.some(
(item) => 'subScope' in item.spec && item.spec.subScope === 'mimir' && item.metadata.name === 'mimir-item-2'
);
expect(hasFilteredItem).toBe(false);
// Verify that valid items are still present
expect(filteredItems.length).toBe(2); // Should have 2 items (mimir-item-1 and mimir-item-3)
expect(filteredItems.some((item) => item.metadata.name === 'mimir-item-1')).toBe(true);
expect(filteredItems.some((item) => item.metadata.name === 'mimir-item-3')).toBe(true);
});
it('should filter out items with subScope matching nested subScope in the path', async () => {
// Mock current location
(locationService.getLocation as jest.Mock).mockReturnValue({ pathname: '/' } as Location);
// Create nested subScope structure: grafana -> mimir -> loki
const grafanaNavigation: ScopeNavigation = {
metadata: { name: 'mimir-nav' },
spec: {
scope: 'grafana',
subScope: 'mimir',
url: '/d/mimir-dashboards',
},
status: {
title: 'Mimir Dashboards',
},
};
// Mock items returned when fetching 'mimir' subScope
// One of them has subScope 'loki', which is fine
const mimirSubScopeItems: ScopeNavigation[] = [
{
metadata: { name: 'mimir-item-1' },
spec: {
scope: 'mimir',
url: '/d/mimir-dashboard-1',
},
status: {
title: 'Mimir Dashboard 1',
groups: ['General'],
},
},
{
metadata: { name: 'loki-nav' },
spec: {
scope: 'mimir',
subScope: 'loki', // This is fine - different subScope
url: '/d/loki-dashboards',
},
status: {
title: 'Loki Dashboards',
},
},
];
// Mock items returned when fetching 'loki' subScope
// One of them has subScope 'mimir', which should be filtered out since 'mimir' is already in the path
const lokiSubScopeItems: ScopeNavigation[] = [
{
metadata: { name: 'loki-item-1' },
spec: {
scope: 'loki',
url: '/d/loki-dashboard-1',
},
status: {
title: 'Loki Dashboard 1',
groups: ['General'],
},
},
{
metadata: { name: 'mimir-nav-again' },
spec: {
scope: 'loki',
subScope: 'mimir', // This should be filtered out - 'mimir' is already in the path
url: '/d/mimir-dashboards-again',
},
status: {
title: 'Mimir Dashboards Again',
},
},
];
// Set up mock to return items based on scope being fetched
mockApiClient.fetchScopeNavigations.mockImplementation((scopeNames: string[]) => {
if (scopeNames.includes('grafana')) {
return Promise.resolve([grafanaNavigation]);
}
if (scopeNames.includes('mimir')) {
return Promise.resolve(mimirSubScopeItems);
}
if (scopeNames.includes('loki')) {
return Promise.resolve(lokiSubScopeItems);
}
return Promise.resolve([]);
});
// Initial fetch to create the first subScope folder
await service.fetchDashboards(['grafana']);
// Set up the folder structure to simulate nested path
const folders = service.groupSuggestedItems([grafanaNavigation]);
const mimirFolders = service.groupSuggestedItems(mimirSubScopeItems);
// Manually construct the nested folder structure for testing
const testFolders: typeof service.state.folders = {
'': {
...folders[''],
folders: {
...folders[''].folders,
'mimir-mimir-nav': {
...folders[''].folders['mimir-mimir-nav'],
folders: {
...mimirFolders[''].folders,
},
},
},
},
};
// Test the filtering logic for nested path
const filteredItems = filterItemsWithSubScopesInPath(
lokiSubScopeItems,
['', 'mimir-mimir-nav', 'loki-loki-nav'],
'loki',
testFolders
);
// Verify that the item with 'mimir' subScope was filtered out
const hasFilteredItem = filteredItems.some(
(item) => 'subScope' in item.spec && item.spec.subScope === 'mimir' && item.metadata.name === 'mimir-nav-again'
);
expect(hasFilteredItem).toBe(false);
// Verify that valid items are still present
expect(filteredItems.length).toBe(1); // Should have 1 item (loki-item-1)
expect(filteredItems.some((item) => item.metadata.name === 'loki-item-1')).toBe(true);
});
});
});
@@ -8,7 +8,7 @@ import { ScopesApiClient } from '../ScopesApiClient';
import { ScopesServiceBase } from '../ScopesServiceBase';
import { isCurrentPath } from './scopeNavgiationUtils';
import { ScopeNavigation, SuggestedNavigationsFoldersMap } from './types';
import { ScopeNavigation, SuggestedNavigationsFoldersMap, SuggestedNavigationsMap } from './types';
interface ScopesDashboardsServiceState {
// State of the drawer showing related dashboards
@@ -106,6 +106,92 @@ export class ScopesDashboardsService extends ScopesServiceBase<ScopesDashboardsS
currentFolder.expanded = expanded;
currentFilteredFolder.expanded = expanded;
// If expanding a subScope folder, fetch items for that subScope asynchronously
if (expanded && currentFolder.subScopeName) {
// Only fetch if folder is empty (hasn't been loaded yet)
const isEmpty =
Object.keys(currentFolder.folders).length === 0 && Object.keys(currentFolder.suggestedNavigations).length === 0;
if (!isEmpty) {
// Folder already has content, skip fetching
this.updateState({ folders, filteredFolders });
return;
}
// Set loading state for this folder
currentFolder.loading = true;
currentFilteredFolder.loading = true;
// Extract the subScope name from the folder (stored when folder was created)
const subScopeName = currentFolder.subScopeName || name;
// Fetch asynchronously without blocking the state update
this.fetchSubScopeItems(path, subScopeName);
} else if (!expanded) {
// Clear loading state when collapsing
currentFolder.loading = false;
currentFilteredFolder.loading = false;
}
this.updateState({ folders, filteredFolders });
};
private fetchSubScopeItems = async (path: string[], subScopeName: string) => {
let subScopeFolders: SuggestedNavigationsFoldersMap | undefined;
try {
// Fetch navigations for this subScope
const fetchNavigations = config.featureToggles.useScopesNavigationEndpoint
? this.apiClient.fetchScopeNavigations
: this.apiClient.fetchDashboards;
const subScopeItems = await fetchNavigations([subScopeName]);
// Filter out items that have a subScope matching any subScope already in the path
// This prevents infinite loops when a subScope returns items with the same subScope
const filteredItems = filterItemsWithSubScopesInPath(subScopeItems, path, subScopeName, this.state.folders);
// Group the items and add them to the subScope folder
subScopeFolders = this.groupSuggestedItems(filteredItems);
} catch (error) {
// On error, subScopeFolders will remain undefined and we'll only clear loading state
}
// Get the current state and navigate to the target folder
let folders = { ...this.state.folders };
let filteredFolders = { ...this.state.filteredFolders };
let currentLevelFolders: SuggestedNavigationsFoldersMap = folders;
let currentLevelFilteredFolders: SuggestedNavigationsFoldersMap = filteredFolders;
for (let idx = 0; idx < path.length - 1; idx++) {
currentLevelFolders = currentLevelFolders[path[idx]].folders;
currentLevelFilteredFolders = currentLevelFilteredFolders[path[idx]].folders;
}
const name = path[path.length - 1];
const currentFolder = currentLevelFolders[name];
const currentFilteredFolder = currentLevelFilteredFolders[name];
// Clear loading state
currentFolder.loading = false;
currentFilteredFolder.loading = false;
// Merge the subScope folder's content with the fetched items (if fetch succeeded)
if (subScopeFolders) {
// Take items from the root of the grouped structure
const rootSubScopeFolder = subScopeFolders[''];
currentFolder.folders = { ...currentFolder.folders, ...rootSubScopeFolder.folders };
currentFolder.suggestedNavigations = {
...currentFolder.suggestedNavigations,
...rootSubScopeFolder.suggestedNavigations,
};
// Also update filtered folders
currentFilteredFolder.folders = { ...currentFilteredFolder.folders, ...rootSubScopeFolder.folders };
currentFilteredFolder.suggestedNavigations = {
...currentFilteredFolder.suggestedNavigations,
...rootSubScopeFolder.suggestedNavigations,
};
}
this.updateState({ folders, filteredFolders });
};
@@ -180,6 +266,7 @@ export class ScopesDashboardsService extends ScopesServiceBase<ScopesDashboardsS
navigationItems.forEach((navigation) => {
const rootNode = folders[''];
const groups = navigation.status.groups ?? [];
const subScope = 'subScope' in navigation.spec ? navigation.spec.subScope : undefined;
// If the current URL matches an item, expand the parent folders.
let expanded = false;
@@ -193,31 +280,8 @@ export class ScopesDashboardsService extends ScopesServiceBase<ScopesDashboardsS
expanded = currentPath.startsWith(navigation.spec.url);
}
groups.forEach((group) => {
const groupExists = !!rootNode.folders[group];
const groupCurrentlyExpanded = groupExists && rootNode.folders[group].expanded;
if (group && !groupExists) {
rootNode.folders[group] = {
title: group,
expanded,
folders: {},
suggestedNavigations: {},
};
}
if (group && expanded && !groupCurrentlyExpanded) {
rootNode.folders[group].expanded = true;
}
});
const targets =
groups.length > 0
? groups.map((group) =>
group === '' ? rootNode.suggestedNavigations : rootNode.folders[group].suggestedNavigations
)
: [rootNode.suggestedNavigations];
targets.forEach((target) => {
// Helper function to add navigation item to a target
const addNavigationToTarget = (target: SuggestedNavigationsMap) => {
// Dashboard
if (
'dashboard' in navigation.spec &&
@@ -236,7 +300,64 @@ export class ScopesDashboardsService extends ScopesServiceBase<ScopesDashboardsS
id: navigation.metadata.name,
};
}
});
};
// Items with subScope completely ignore groups and go to a separate section
// They only create expandable folders, not navigation items
// Multiple items with the same subScope create separate folders that reference the same subScope
if (subScope) {
const navigationTitle =
('title' in navigation.status && navigation.status.title) ||
('dashboardTitle' in navigation.status && navigation.status.dashboardTitle) ||
navigation.metadata.name;
// Create a separate folder for each item, using a unique key
// All folders with the same subScope will load the same content when expanded
const folderKey = `${subScope}-${navigation.metadata.name}`;
if (!rootNode.folders[folderKey]) {
rootNode.folders[folderKey] = {
title: navigationTitle,
expanded,
folders: {},
suggestedNavigations: {},
subScopeName: subScope,
};
}
if (expanded && !rootNode.folders[folderKey].expanded) {
rootNode.folders[folderKey].expanded = true;
}
// Don't add the navigation item - it only creates/updates the folder
} else {
// Items without subScope: add to group folders (if groups exist) or root
if (groups.length > 0) {
// Add item to all group folders at root level
// Each group gets the item separately, not nested
groups.forEach((group) => {
if (group) {
if (!rootNode.folders[group]) {
rootNode.folders[group] = {
title: group,
expanded,
folders: {},
suggestedNavigations: {},
};
}
if (expanded && !rootNode.folders[group].expanded) {
rootNode.folders[group].expanded = true;
}
// Add the navigation item directly to the group folder
addNavigationToTarget(rootNode.folders[group].suggestedNavigations);
} else {
// Empty string group means add to root folder
addNavigationToTarget(rootNode.suggestedNavigations);
}
});
} else {
// If no groups, add to root
addNavigationToTarget(rootNode.suggestedNavigations);
}
}
});
return folders;
@@ -277,3 +398,43 @@ export class ScopesDashboardsService extends ScopesServiceBase<ScopesDashboardsS
public toggleDrawer = () => this.updateState({ drawerOpened: !this.state.drawerOpened });
}
/**
* Filters out navigation items that have a subScope matching any subScope already in the path.
* This prevents infinite loops when a subScope returns items with the same subScope.
* @param items - The navigation items to filter
* @param path - The folder path to check for existing subScopes
* @param currentSubScope - The subScope currently being expanded
* @param folders - The folder structure to traverse
* @returns Filtered items without subScopes that would create infinite loops
*/
export function filterItemsWithSubScopesInPath(
items: Array<ScopeDashboardBinding | ScopeNavigation>,
path: string[],
currentSubScope: string,
folders: SuggestedNavigationsFoldersMap
): Array<ScopeDashboardBinding | ScopeNavigation> {
const subScopesInPath = new Set<string>();
// Include the current subScope being expanded
subScopesInPath.add(currentSubScope);
// Traverse the path and collect all subScope names
let currentLevelFolders: SuggestedNavigationsFoldersMap = folders;
for (const folderKey of path) {
const folder = currentLevelFolders[folderKey];
if (folder?.subScopeName) {
subScopesInPath.add(folder.subScopeName);
}
if (folder) {
currentLevelFolders = folder.folders;
} else {
// If folder is not found, break to avoid errors
break;
}
}
return items.filter((item) => {
const itemSubScope = 'subScope' in item.spec ? item.spec.subScope : undefined;
return !itemSubScope || !subScopesInPath.has(itemSubScope);
});
}
@@ -1,4 +1,7 @@
import { urlUtil } from '@grafana/data';
import { css } from '@emotion/css';
import { GrafanaTheme2, urlUtil } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
import { useQueryParams } from 'app/core/hooks/useQueryParams';
import { ScopesDashboardsTreeFolderItem } from './ScopesDashboardsTreeFolderItem';
@@ -13,13 +16,32 @@ export interface ScopesDashboardsTreeProps {
export function ScopesDashboardsTree({ folders, folderPath, onFolderUpdate }: ScopesDashboardsTreeProps) {
const [queryParams] = useQueryParams();
const styles = useStyles2(getStyles);
const folderId = folderPath[folderPath.length - 1];
const folder = folders[folderId];
// Separate regular items from subScope items
const regularFolders: Array<[string, (typeof folder.folders)[string]]> = [];
const subScopeFolders: Array<[string, (typeof folder.folders)[string]]> = [];
Object.entries(folder.folders).forEach(([subFolderId, subFolder]) => {
if (subFolder.subScopeName) {
subScopeFolders.push([subFolderId, subFolder]);
} else {
regularFolders.push([subFolderId, subFolder]);
}
});
const regularNavigations = Object.values(folder.suggestedNavigations);
const hasRegularContent = regularFolders.length > 0 || regularNavigations.length > 0;
const hasSubScopeContent = subScopeFolders.length > 0;
return (
<div role="tree">
{Object.entries(folder.folders).map(([subFolderId, subFolder]) => (
{/* Regular folders and navigations */}
{regularFolders.map(([subFolderId, subFolder]) => (
<ScopesDashboardsTreeFolderItem
key={subFolderId}
folder={subFolder}
@@ -28,7 +50,7 @@ export function ScopesDashboardsTree({ folders, folderPath, onFolderUpdate }: Sc
onFolderUpdate={onFolderUpdate}
/>
))}
{Object.values(folder.suggestedNavigations).map((navigation) => (
{regularNavigations.map((navigation) => (
<ScopesNavigationTreeLink
key={navigation.id + navigation.title}
to={urlUtil.renderUrl(navigation.url, queryParams)}
@@ -36,6 +58,28 @@ export function ScopesDashboardsTree({ folders, folderPath, onFolderUpdate }: Sc
id={navigation.id}
/>
))}
{/* Separator between regular and subScope sections */}
{hasRegularContent && hasSubScopeContent && <hr className={styles.separator} />}
{/* SubScope folders */}
{subScopeFolders.map(([subFolderId, subFolder]) => (
<ScopesDashboardsTreeFolderItem
key={subFolderId}
folder={subFolder}
folders={folder.folders}
folderPath={[...folderPath, subFolderId]}
onFolderUpdate={onFolderUpdate}
/>
))}
</div>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
separator: css({
border: 'none',
borderTop: `1px solid ${theme.colors.border.weak}`,
margin: theme.spacing(1, 0),
}),
});
@@ -2,7 +2,9 @@ import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { t } from '@grafana/i18n';
import { Icon, useStyles2 } from '@grafana/ui';
import { Icon, IconButton, Spinner, useStyles2 } from '@grafana/ui';
import { useScopesServices } from '../ScopesContextProvider';
import { ScopesDashboardsTree } from './ScopesDashboardsTree';
import { OnFolderUpdate, SuggestedNavigationsFolder, SuggestedNavigationsFoldersMap } from './types';
@@ -22,22 +24,45 @@ export function ScopesDashboardsTreeFolderItem({
}: ScopesDashboardsTreeFolderItemProps) {
const styles = useStyles2(getStyles);
// get scopesselector service
const scopesSelectorService = useScopesServices()?.scopesSelectorService ?? undefined;
return (
<div className={styles.container} role="treeitem" aria-selected={folder.expanded}>
<button
className={styles.expand}
data-testid={`scopes-dashboards-${folder.title}-expand`}
aria-label={
folder.expanded ? t('scopes.dashboards.collapse', 'Collapse') : t('scopes.dashboards.expand', 'Expand')
}
onClick={() => {
onFolderUpdate(folderPath, !folder.expanded);
}}
>
<Icon name={!folder.expanded ? 'angle-right' : 'angle-down'} className={styles.icon} />
<div className={styles.row}>
<button
className={styles.expand}
data-testid={`scopes-dashboards-${folder.title}-expand`}
aria-label={
folder.expanded ? t('scopes.dashboards.collapse', 'Collapse') : t('scopes.dashboards.expand', 'Expand')
}
onClick={() => {
onFolderUpdate(folderPath, !folder.expanded);
}}
>
<Icon name={!folder.expanded ? 'angle-right' : 'angle-down'} className={styles.icon} />
{folder.title}
</button>
<span className={styles.titleContainer}>{folder.title}</span>
{folder.loading && <Spinner inline size="sm" className={styles.loadingIcon} />}
</button>
{folder.subScopeName && (
<IconButton
className={styles.exchangeIcon}
tooltip={t('scopes.dashboards.exchange', 'Change root scope to {{scope}}', {
scope: folder.subScopeName || '',
})}
name="exchange-alt"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (folder.subScopeName && scopesSelectorService) {
scopesSelectorService.changeScopes([folder.subScopeName]);
}
}}
/>
)}
</div>
{folder.expanded && (
<div className={styles.children}>
@@ -55,6 +80,12 @@ const getStyles = (theme: GrafanaTheme2) => {
flexDirection: 'column',
padding: theme.spacing(0.5, 0),
}),
row: css({
display: 'flex',
alignItems: 'flex-start',
gap: theme.spacing(1),
width: '100%',
}),
expand: css({
alignItems: 'flex-start',
background: 'none',
@@ -65,12 +96,30 @@ const getStyles = (theme: GrafanaTheme2) => {
padding: 0,
textAlign: 'left',
wordBreak: 'break-word',
flex: 1,
}),
icon: css({
marginTop: theme.spacing(0.25),
}),
titleContainer: css({
display: 'flex',
alignItems: 'center',
flex: 1,
}),
exchangeIcon: css({
opacity: 0.7,
flexShrink: 0,
marginTop: theme.spacing(0.25),
}),
loadingIcon: css({
flexShrink: 0,
marginLeft: theme.spacing(0.5),
marginTop: theme.spacing(0.25),
}),
children: css({
paddingLeft: theme.spacing(3),
paddingLeft: theme.spacing(2),
marginLeft: theme.spacing(1),
borderLeft: `1px solid ${theme.colors.border.weak}`,
}),
};
};
@@ -4,6 +4,7 @@ import { ScopeDashboardBinding } from '@grafana/data';
export interface ScopeNavigationSpec {
url: string;
scope: string;
subScope?: string;
}
export interface ScopeNavigationStatus {
@@ -37,6 +38,8 @@ export interface SuggestedNavigationsFolder {
expanded: boolean;
folders: SuggestedNavigationsFoldersMap;
suggestedNavigations: SuggestedNavigationsMap;
subScopeName?: string;
loading?: boolean;
}
export type SuggestedNavigationsFoldersMap = Record<string, SuggestedNavigationsFolder>;
@@ -1,7 +1,8 @@
import { waitFor } from '@testing-library/dom';
import { screen, waitFor } from '@testing-library/react';
import { config, locationService } from '@grafana/runtime';
import { ScopesApiClient } from '../ScopesApiClient';
import { ScopesService } from '../ScopesService';
import { ScopesDashboardsService } from '../dashboards/ScopesDashboardsService';
import { ScopeNavigation } from '../dashboards/types';
@@ -38,6 +39,11 @@ import {
getDatasource,
getInstanceSettings,
getMock,
navigationWithSubScope,
navigationWithSubScope2,
navigationWithSubScopeDifferent,
navigationWithSubScopeAndGroups,
subScopeMimirItems,
} from './utils/mocks';
import { renderDashboard, resetScenes } from './utils/render';
@@ -52,8 +58,10 @@ jest.mock('@grafana/runtime', () => ({
describe('Dashboards list', () => {
let fetchDashboardsSpy: jest.SpyInstance;
let fetchScopeNavigationsSpy: jest.SpyInstance;
let scopesService: ScopesService;
let scopesDashboardsService: ScopesDashboardsService;
let apiClient: ScopesApiClient;
beforeAll(() => {
config.featureToggles.scopeFilters = true;
@@ -64,12 +72,14 @@ describe('Dashboards list', () => {
const result = await renderDashboard();
scopesService = result.scopesService;
scopesDashboardsService = result.scopesDashboardsService;
fetchDashboardsSpy = jest.spyOn(result.client, 'fetchDashboards');
apiClient = result.client;
fetchDashboardsSpy = jest.spyOn(apiClient, 'fetchDashboards');
fetchScopeNavigationsSpy = jest.spyOn(apiClient, 'fetchScopeNavigations');
});
afterEach(async () => {
locationService.replace('');
await resetScenes([fetchDashboardsSpy]);
await resetScenes([fetchDashboardsSpy, fetchScopeNavigationsSpy]);
});
it('Opens container and fetches dashboards list when a scope is selected', async () => {
@@ -508,6 +518,246 @@ describe('Dashboards list', () => {
});
});
describe('subScopes', () => {
beforeAll(() => {
config.featureToggles.useScopesNavigationEndpoint = true;
});
afterAll(() => {
config.featureToggles.useScopesNavigationEndpoint = false;
});
it('Creates subScope folders when navigation items have subScope', async () => {
const mockNavigations = [navigationWithSubScope, navigationWithSubScopeDifferent];
fetchScopeNavigationsSpy.mockResolvedValue(mockNavigations);
await toggleDashboards();
await updateScopes(scopesService, ['grafana']);
await jest.runOnlyPendingTimersAsync();
// Verify subScope folders are created
expect(screen.getByTestId('scopes-dashboards-Mimir Dashboards-expand')).toBeInTheDocument();
expect(screen.getByTestId('scopes-dashboards-Loki Dashboards-expand')).toBeInTheDocument();
});
it('Loads subScope items when folder is expanded', async () => {
const mockNavigations = [navigationWithSubScope];
fetchScopeNavigationsSpy.mockResolvedValueOnce(mockNavigations).mockResolvedValueOnce(subScopeMimirItems);
await toggleDashboards();
await updateScopes(scopesService, ['grafana']);
await jest.runOnlyPendingTimersAsync();
// Verify folder appears
expect(screen.getByTestId('scopes-dashboards-Mimir Dashboards-expand')).toBeInTheDocument();
// Expand the subScope folder
await expandDashboardFolder('Mimir Dashboards');
// Wait for async fetchSubScopeItems to complete
await waitFor(() => {
expect(fetchScopeNavigationsSpy).toHaveBeenCalledWith(['mimir']);
});
await jest.runOnlyPendingTimersAsync();
// Items are added to nested folders within the subScope folder, so expand those folders
await expandDashboardFolder('General');
await expandDashboardFolder('Observability');
await jest.runOnlyPendingTimersAsync();
// Verify loaded content appears (IDs are based on metadata.name)
await waitFor(() => {
expectDashboardInDocument('mimir-item-1');
});
expectDashboardInDocument('mimir-item-2');
});
it('Shows loading state while fetching subScope items', async () => {
const mockNavigations = [navigationWithSubScope];
fetchScopeNavigationsSpy.mockResolvedValueOnce(mockNavigations).mockResolvedValueOnce(subScopeMimirItems);
await toggleDashboards();
await updateScopes(scopesService, ['grafana']);
await jest.runOnlyPendingTimersAsync();
// Verify folder appears
expect(screen.getByTestId('scopes-dashboards-Mimir Dashboards-expand')).toBeInTheDocument();
// Expand the subScope folder
await expandDashboardFolder('Mimir Dashboards');
// Verify fetch was called (loading happens asynchronously)
await waitFor(() => {
expect(fetchScopeNavigationsSpy).toHaveBeenCalledWith(['mimir']);
});
});
it('Multiple subScope folders with same subScope load same content', async () => {
const mockNavigations = [navigationWithSubScope, navigationWithSubScope2];
fetchScopeNavigationsSpy.mockResolvedValueOnce(mockNavigations).mockResolvedValue(subScopeMimirItems);
await toggleDashboards();
await updateScopes(scopesService, ['grafana']);
await jest.runOnlyPendingTimersAsync();
// Verify folders appear
expect(screen.getByTestId('scopes-dashboards-Mimir Dashboards-expand')).toBeInTheDocument();
expect(screen.getByTestId('scopes-dashboards-Mimir Overview-expand')).toBeInTheDocument();
// Expand first subScope folder
await expandDashboardFolder('Mimir Dashboards');
// Wait for fetch to complete
await waitFor(() => {
expect(fetchScopeNavigationsSpy).toHaveBeenCalledWith(['mimir']);
});
await jest.runOnlyPendingTimersAsync();
// Expand nested folders to see the content in first subScope folder
await expandDashboardFolder('General');
await expandDashboardFolder('Observability');
await jest.runOnlyPendingTimersAsync();
// Verify content appears in first folder
await waitFor(() => {
expectDashboardInDocument('mimir-item-1');
});
expectDashboardInDocument('mimir-item-2');
// Expand second subScope folder (same subScope) - it should load the same content
await expandDashboardFolder('Mimir Overview');
// Wait for fetch to complete (should use cached data or fetch again)
await waitFor(() => {
// The fetch might be called again or might use cached content
expect(fetchScopeNavigationsSpy.mock.calls.length).toBeGreaterThanOrEqual(1);
});
await jest.runOnlyPendingTimersAsync();
// Both folders should have the same content (IDs are based on metadata.name)
// Since nested folders are already expanded, content should be visible
expectDashboardInDocument('mimir-item-1');
expectDashboardInDocument('mimir-item-2');
});
it('Handles errors when fetching subScope items', async () => {
const mockNavigations = [navigationWithSubScope];
fetchScopeNavigationsSpy.mockResolvedValueOnce(mockNavigations).mockRejectedValueOnce(new Error('Fetch failed'));
await toggleDashboards();
await updateScopes(scopesService, ['grafana']);
await jest.runOnlyPendingTimersAsync();
// Verify folder appears
expect(screen.getByTestId('scopes-dashboards-Mimir Dashboards-expand')).toBeInTheDocument();
// Expand the subScope folder
await expandDashboardFolder('Mimir Dashboards');
await jest.runOnlyPendingTimersAsync();
// Verify fetch was called
expect(fetchScopeNavigationsSpy).toHaveBeenCalledWith(['mimir']);
// Verify no content appears (error handled gracefully)
expectDashboardNotInDocument('mimir-item-1');
});
it('Ignores groups for subScope items', async () => {
const mockNavigations = [navigationWithSubScopeAndGroups];
fetchScopeNavigationsSpy.mockResolvedValue(mockNavigations);
await toggleDashboards();
await updateScopes(scopesService, ['grafana']);
await jest.runOnlyPendingTimersAsync();
// Verify subScope folder is created (groups should be ignored)
expect(screen.getByTestId('scopes-dashboards-Mimir with Groups-expand')).toBeInTheDocument();
// The folder should exist regardless of groups
expect(fetchScopeNavigationsSpy).toHaveBeenCalled();
});
it('Filters search works with loaded subScope content', async () => {
const mockNavigations = [navigationWithSubScope];
fetchScopeNavigationsSpy.mockResolvedValueOnce(mockNavigations).mockResolvedValueOnce(subScopeMimirItems);
await toggleDashboards();
await updateScopes(scopesService, ['grafana']);
await jest.runOnlyPendingTimersAsync();
// Verify folder appears
expect(screen.getByTestId('scopes-dashboards-Mimir Dashboards-expand')).toBeInTheDocument();
// Expand subScope folder and load content
await expandDashboardFolder('Mimir Dashboards');
// Wait for fetch to complete
await waitFor(() => {
expect(fetchScopeNavigationsSpy).toHaveBeenCalledWith(['mimir']);
});
await jest.runOnlyPendingTimersAsync();
// Expand nested folders to see the content
await expandDashboardFolder('General');
await expandDashboardFolder('Observability');
await jest.runOnlyPendingTimersAsync();
// Verify content is loaded
await waitFor(() => {
expectDashboardInDocument('mimir-item-1');
});
expectDashboardInDocument('mimir-item-2');
// Search for a dashboard in the subScope
await searchDashboards('Mimir Dashboard 1');
// Verify search works (IDs are based on metadata.name)
expectDashboardInDocument('mimir-item-1');
expectDashboardNotInDocument('mimir-item-2');
});
it('Does not fetch subScope items if folder is already loaded', async () => {
const mockNavigations = [navigationWithSubScope];
fetchScopeNavigationsSpy.mockResolvedValueOnce(mockNavigations).mockResolvedValueOnce(subScopeMimirItems);
await toggleDashboards();
await updateScopes(scopesService, ['grafana']);
await jest.runOnlyPendingTimersAsync();
// Verify folder appears
expect(screen.getByTestId('scopes-dashboards-Mimir Dashboards-expand')).toBeInTheDocument();
// Expand the subScope folder first time
await expandDashboardFolder('Mimir Dashboards');
// Wait for fetch to complete
await waitFor(() => {
expect(fetchScopeNavigationsSpy).toHaveBeenCalledWith(['mimir']);
});
await jest.runOnlyPendingTimersAsync();
// Expand nested folders to see the content
await expandDashboardFolder('General');
await jest.runOnlyPendingTimersAsync();
// Verify content is loaded
await waitFor(() => {
expectDashboardInDocument('mimir-item-1');
});
const firstCallCount = fetchScopeNavigationsSpy.mock.calls.length;
// Collapse and expand again
await expandDashboardFolder('Mimir Dashboards'); // Collapse
await expandDashboardFolder('Mimir Dashboards'); // Expand again
await jest.runOnlyPendingTimersAsync();
// Should not fetch again if already loaded
// Note: This test might need adjustment based on actual implementation
expect(fetchScopeNavigationsSpy.mock.calls.length).toBeGreaterThanOrEqual(firstCallCount);
});
});
describe('filterFolders', () => {
it('Shows folders matching criteria', () => {
expect(
@@ -2,6 +2,8 @@ import { Scope, ScopeDashboardBinding, ScopeNode } from '@grafana/data';
import { DataSourceRef } from '@grafana/schema/dist/esm/common/common.gen';
import { getDashboardScenePageStateManager } from 'app/features/dashboard-scene/pages/DashboardScenePageStateManager';
import { ScopeNavigation } from '../../dashboards/types';
export const mocksScopes: Scope[] = [
{
metadata: { name: 'cloud' },
@@ -418,6 +420,24 @@ export const getMock = jest
};
}
if (url.startsWith('/apis/scope.grafana.app/v0alpha1/namespaces/default/find/scope_navigations')) {
// Handle subScope fetch requests
if (params.scope && params.scope.includes('mimir')) {
return {
items: subScopeMimirItems,
};
}
if (params.scope && params.scope.includes('loki')) {
return {
items: subScopeLokiItems,
};
}
// Return empty for other scopes
return {
items: [],
};
}
if (url.startsWith('/api/dashboards/uid/')) {
return {};
}
@@ -470,6 +490,99 @@ export const dashboardWithRootFolderAndOtherFolder: ScopeDashboardBinding = gene
['', 'Folder 3']
);
// Mock subScope navigation items
export const navigationWithSubScope: ScopeNavigation = {
metadata: { name: 'subscope-nav-1' },
spec: {
scope: 'grafana',
subScope: 'mimir',
url: '/d/subscope-dashboard-1',
},
status: {
title: 'Mimir Dashboards',
groups: [], // subScope items ignore groups
},
};
export const navigationWithSubScope2: ScopeNavigation = {
metadata: { name: 'subscope-nav-2' },
spec: {
scope: 'grafana',
subScope: 'mimir',
url: '/d/subscope-dashboard-2',
},
status: {
title: 'Mimir Overview',
groups: [],
},
};
export const navigationWithSubScopeDifferent: ScopeNavigation = {
metadata: { name: 'subscope-nav-3' },
spec: {
scope: 'grafana',
subScope: 'loki',
url: '/d/subscope-dashboard-3',
},
status: {
title: 'Loki Dashboards',
groups: [],
},
};
export const navigationWithSubScopeAndGroups: ScopeNavigation = {
metadata: { name: 'subscope-nav-groups' },
spec: {
scope: 'grafana',
subScope: 'mimir',
url: '/d/subscope-dashboard-groups',
},
status: {
title: 'Mimir with Groups',
groups: ['Group1', 'Group2'], // Should be ignored for subScope items
},
};
// Mock items that will be loaded when subScope folder is expanded
export const subScopeMimirItems: ScopeNavigation[] = [
{
metadata: { name: 'mimir-item-1' },
spec: {
scope: 'mimir',
url: '/d/mimir-dashboard-1',
},
status: {
title: 'Mimir Dashboard 1',
groups: ['General'],
},
},
{
metadata: { name: 'mimir-item-2' },
spec: {
scope: 'mimir',
url: '/d/mimir-dashboard-2',
},
status: {
title: 'Mimir Dashboard 2',
groups: ['Observability'],
},
},
];
export const subScopeLokiItems: ScopeNavigation[] = [
{
metadata: { name: 'loki-item-1' },
spec: {
scope: 'loki',
url: '/d/loki-dashboard-1',
},
status: {
title: 'Loki Dashboard 1',
groups: ['General'],
},
},
];
export const getDatasource = async (ref: DataSourceRef) => {
if (ref.uid === '-- Grafana --') {
return {
+1
View File
@@ -12384,6 +12384,7 @@
"scopes": {
"dashboards": {
"collapse": "Collapse",
"exchange": "Change root scope to {{scope}}",
"expand": "Expand",
"loading": "Loading dashboards",
"noResultsForFilter": "No results found for your query",