Skip to content

Commit 605bc81

Browse files
authoredAug 26, 2024
Scopes: Group suggested dashboards (grafana#92212)
1 parent 1dd830b commit 605bc81

20 files changed

+2131
-1195
lines changed
 

‎packages/grafana-data/src/types/scopes.ts

+1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ export interface ScopeDashboardBindingSpec {
22
dashboard: string;
33
dashboardTitle: string;
44
scope: string;
5+
groups?: string[];
56
}
67

78
// TODO: Use Resource from apiserver when we export the types

‎public/app/features/scopes/ScopesFacadeScene.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { isEqual } from 'lodash';
2+
13
import { SceneObjectBase, SceneObjectState } from '@grafana/scenes';
24

35
import { scopesSelectorScene } from './instance';
@@ -20,7 +22,7 @@ export class ScopesFacade extends SceneObjectBase<ScopesFacadeState> {
2022

2123
this._subs.add(
2224
scopesSelectorScene?.subscribeToState((newState, prevState) => {
23-
if (!newState.isLoadingScopes && (prevState.isLoadingScopes || newState.scopes !== prevState.scopes)) {
25+
if (!newState.isLoadingScopes && (prevState.isLoadingScopes || !isEqual(newState.scopes, prevState.scopes))) {
2426
this.state.handler?.(this);
2527
}
2628
})

‎public/app/features/scopes/internal/ScopesDashboardsScene.tsx

+60-57
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,27 @@
11
import { css, cx } from '@emotion/css';
22
import { isEqual } from 'lodash';
3-
import { Link } from 'react-router-dom';
43

5-
import { GrafanaTheme2, urlUtil } from '@grafana/data';
4+
import { GrafanaTheme2, ScopeDashboardBinding } from '@grafana/data';
65
import { SceneComponentProps, SceneObjectBase, SceneObjectRef, SceneObjectState } from '@grafana/scenes';
7-
import { Button, CustomScrollbar, FilterInput, LoadingPlaceholder, useStyles2 } from '@grafana/ui';
8-
import { useQueryParams } from 'app/core/hooks/useQueryParams';
6+
import { Button, CustomScrollbar, LoadingPlaceholder, useStyles2 } from '@grafana/ui';
97
import { t, Trans } from 'app/core/internationalization';
108

9+
import { ScopesDashboardsTree } from './ScopesDashboardsTree';
10+
import { ScopesDashboardsTreeSearch } from './ScopesDashboardsTreeSearch';
1111
import { ScopesSelectorScene } from './ScopesSelectorScene';
12-
import { fetchSuggestedDashboards } from './api';
12+
import { fetchDashboards } from './api';
1313
import { DASHBOARDS_OPENED_KEY } from './const';
14-
import { SuggestedDashboard } from './types';
15-
import { getScopeNamesFromSelectedScopes } from './utils';
14+
import { SuggestedDashboardsFoldersMap } from './types';
15+
import { filterFolders, getScopeNamesFromSelectedScopes, groupDashboards } from './utils';
1616

1717
export interface ScopesDashboardsSceneState extends SceneObjectState {
1818
selector: SceneObjectRef<ScopesSelectorScene> | null;
19-
dashboards: SuggestedDashboard[];
20-
filteredDashboards: SuggestedDashboard[];
19+
// by keeping a track of the raw response, it's much easier to check if we got any dashboards for the currently selected scopes
20+
dashboards: ScopeDashboardBinding[];
21+
// this is a grouping in folders of the `dashboards` property. it is used for filtering the dashboards and folders when the search query changes
22+
folders: SuggestedDashboardsFoldersMap;
23+
// a filtered version of the `folders` property. this prevents a lot of unnecessary parsings in React renders
24+
filteredFolders: SuggestedDashboardsFoldersMap;
2125
forScopeNames: string[];
2226
isLoading: boolean;
2327
isPanelOpened: boolean;
@@ -28,7 +32,8 @@ export interface ScopesDashboardsSceneState extends SceneObjectState {
2832

2933
export const getInitialDashboardsState: () => Omit<ScopesDashboardsSceneState, 'selector'> = () => ({
3034
dashboards: [],
31-
filteredDashboards: [],
35+
folders: {},
36+
filteredFolders: {},
3237
forScopeNames: [],
3338
isLoading: false,
3439
isPanelOpened: localStorage.getItem(DASHBOARDS_OPENED_KEY) === 'true',
@@ -80,7 +85,8 @@ export class ScopesDashboardsScene extends SceneObjectBase<ScopesDashboardsScene
8085
if (scopeNames.length === 0) {
8186
return this.setState({
8287
dashboards: [],
83-
filteredDashboards: [],
88+
folders: {},
89+
filteredFolders: {},
8490
forScopeNames: [],
8591
isLoading: false,
8692
scopesSelected: false,
@@ -89,26 +95,50 @@ export class ScopesDashboardsScene extends SceneObjectBase<ScopesDashboardsScene
8995

9096
this.setState({ isLoading: true });
9197

92-
const dashboards = await fetchSuggestedDashboards(scopeNames);
98+
const dashboards = await fetchDashboards(scopeNames);
99+
const folders = groupDashboards(dashboards);
100+
const filteredFolders = filterFolders(folders, this.state.searchQuery);
93101

94102
this.setState({
95103
dashboards,
96-
filteredDashboards: this.filterDashboards(dashboards, this.state.searchQuery),
104+
folders,
105+
filteredFolders,
97106
forScopeNames: scopeNames,
98107
isLoading: false,
99108
scopesSelected: scopeNames.length > 0,
100109
});
101110
}
102111

103112
public changeSearchQuery(searchQuery: string) {
113+
searchQuery = searchQuery ?? '';
114+
104115
this.setState({
105-
filteredDashboards: searchQuery
106-
? this.filterDashboards(this.state.dashboards, searchQuery)
107-
: this.state.dashboards,
108-
searchQuery: searchQuery ?? '',
116+
filteredFolders: filterFolders(this.state.folders, searchQuery),
117+
searchQuery,
109118
});
110119
}
111120

121+
public updateFolder(path: string[], isExpanded: boolean) {
122+
let folders = { ...this.state.folders };
123+
let filteredFolders = { ...this.state.filteredFolders };
124+
let currentLevelFolders: SuggestedDashboardsFoldersMap = folders;
125+
let currentLevelFilteredFolders: SuggestedDashboardsFoldersMap = filteredFolders;
126+
127+
for (let idx = 0; idx < path.length - 1; idx++) {
128+
currentLevelFolders = currentLevelFolders[path[idx]].folders;
129+
currentLevelFilteredFolders = currentLevelFilteredFolders[path[idx]].folders;
130+
}
131+
132+
const name = path[path.length - 1];
133+
const currentFolder = currentLevelFolders[name];
134+
const currentFilteredFolder = currentLevelFilteredFolders[name];
135+
136+
currentFolder.isExpanded = isExpanded;
137+
currentFilteredFolder.isExpanded = isExpanded;
138+
139+
this.setState({ folders, filteredFolders });
140+
}
141+
112142
public togglePanel() {
113143
if (this.state.isPanelOpened) {
114144
this.closePanel();
@@ -135,20 +165,13 @@ export class ScopesDashboardsScene extends SceneObjectBase<ScopesDashboardsScene
135165
public disable() {
136166
this.setState({ isEnabled: false });
137167
}
138-
139-
private filterDashboards(dashboards: SuggestedDashboard[], searchQuery: string): SuggestedDashboard[] {
140-
const lowerCasedSearchQuery = searchQuery.toLowerCase();
141-
142-
return dashboards.filter(({ dashboardTitle }) => dashboardTitle.toLowerCase().includes(lowerCasedSearchQuery));
143-
}
144168
}
145169

146170
export function ScopesDashboardsSceneRenderer({ model }: SceneComponentProps<ScopesDashboardsScene>) {
147-
const { dashboards, filteredDashboards, isLoading, isPanelOpened, isEnabled, searchQuery, scopesSelected } =
171+
const { dashboards, filteredFolders, isLoading, isPanelOpened, isEnabled, searchQuery, scopesSelected } =
148172
model.useState();
149-
const styles = useStyles2(getStyles);
150173

151-
const [queryParams] = useQueryParams();
174+
const styles = useStyles2(getStyles);
152175

153176
if (!isEnabled || !isPanelOpened) {
154177
return null;
@@ -178,34 +201,25 @@ export function ScopesDashboardsSceneRenderer({ model }: SceneComponentProps<Sco
178201

179202
return (
180203
<div className={styles.container} data-testid="scopes-dashboards-container">
181-
<div className={styles.searchInputContainer}>
182-
<FilterInput
183-
disabled={isLoading}
184-
placeholder={t('scopes.dashboards.search', 'Search')}
185-
value={searchQuery}
186-
data-testid="scopes-dashboards-search"
187-
onChange={(value) => model.changeSearchQuery(value)}
188-
/>
189-
</div>
204+
<ScopesDashboardsTreeSearch
205+
disabled={isLoading}
206+
query={searchQuery}
207+
onChange={(value) => model.changeSearchQuery(value)}
208+
/>
190209

191210
{isLoading ? (
192211
<LoadingPlaceholder
193212
className={styles.loadingIndicator}
194213
text={t('scopes.dashboards.loading', 'Loading dashboards')}
195214
data-testid="scopes-dashboards-loading"
196215
/>
197-
) : filteredDashboards.length > 0 ? (
216+
) : filteredFolders[''] ? (
198217
<CustomScrollbar>
199-
{filteredDashboards.map(({ dashboard, dashboardTitle }) => (
200-
<Link
201-
key={dashboard}
202-
to={urlUtil.renderUrl(`/d/${dashboard}/`, queryParams)}
203-
className={styles.dashboardItem}
204-
data-testid={`scopes-dashboards-${dashboard}`}
205-
>
206-
{dashboardTitle}
207-
</Link>
208-
))}
218+
<ScopesDashboardsTree
219+
folders={filteredFolders}
220+
folderPath={['']}
221+
onFolderUpdate={(path, isExpanded) => model.updateFolder(path, isExpanded)}
222+
/>
209223
</CustomScrollbar>
210224
) : (
211225
<p className={styles.noResultsContainer} data-testid="scopes-dashboards-notFoundForFilter">
@@ -246,19 +260,8 @@ const getStyles = (theme: GrafanaTheme2) => {
246260
margin: 0,
247261
textAlign: 'center',
248262
}),
249-
searchInputContainer: css({
250-
flex: '0 1 auto',
251-
}),
252263
loadingIndicator: css({
253264
alignSelf: 'center',
254265
}),
255-
dashboardItem: css({
256-
padding: theme.spacing(1, 0),
257-
borderBottom: `1px solid ${theme.colors.border.weak}`,
258-
259-
'& :is(:first-child)': {
260-
paddingTop: 0,
261-
},
262-
}),
263266
};
264267
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { ScopesDashboardsTreeDashboardItem } from './ScopesDashboardsTreeDashboardItem';
2+
import { ScopesDashboardsTreeFolderItem } from './ScopesDashboardsTreeFolderItem';
3+
import { OnFolderUpdate, SuggestedDashboardsFoldersMap } from './types';
4+
5+
export interface ScopesDashboardsTreeProps {
6+
folders: SuggestedDashboardsFoldersMap;
7+
folderPath: string[];
8+
onFolderUpdate: OnFolderUpdate;
9+
}
10+
11+
export function ScopesDashboardsTree({ folders, folderPath, onFolderUpdate }: ScopesDashboardsTreeProps) {
12+
const folderId = folderPath[folderPath.length - 1];
13+
const folder = folders[folderId];
14+
15+
return (
16+
<div role="tree">
17+
{Object.entries(folder.folders).map(([subFolderId, subFolder]) => (
18+
<ScopesDashboardsTreeFolderItem
19+
key={subFolderId}
20+
folder={subFolder}
21+
folders={folder.folders}
22+
folderPath={[...folderPath, subFolderId]}
23+
onFolderUpdate={onFolderUpdate}
24+
/>
25+
))}
26+
27+
{Object.values(folder.dashboards).map((dashboard) => (
28+
<ScopesDashboardsTreeDashboardItem key={dashboard.dashboard} dashboard={dashboard} />
29+
))}
30+
</div>
31+
);
32+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { css } from '@emotion/css';
2+
import { Link } from 'react-router-dom';
3+
4+
import { GrafanaTheme2, urlUtil } from '@grafana/data';
5+
import { Icon, useStyles2 } from '@grafana/ui';
6+
import { useQueryParams } from 'app/core/hooks/useQueryParams';
7+
8+
import { SuggestedDashboard } from './types';
9+
10+
export interface ScopesDashboardsTreeDashboardItemProps {
11+
dashboard: SuggestedDashboard;
12+
}
13+
14+
export function ScopesDashboardsTreeDashboardItem({ dashboard }: ScopesDashboardsTreeDashboardItemProps) {
15+
const styles = useStyles2(getStyles);
16+
17+
const [queryParams] = useQueryParams();
18+
19+
return (
20+
<Link
21+
key={dashboard.dashboard}
22+
to={urlUtil.renderUrl(`/d/${dashboard.dashboard}/`, queryParams)}
23+
className={styles.container}
24+
data-testid={`scopes-dashboards-${dashboard.dashboard}`}
25+
role="treeitem"
26+
>
27+
<Icon name="apps" /> {dashboard.dashboardTitle}
28+
</Link>
29+
);
30+
}
31+
32+
const getStyles = (theme: GrafanaTheme2) => {
33+
return {
34+
container: css({
35+
display: 'flex',
36+
alignItems: 'center',
37+
gap: theme.spacing(1),
38+
padding: theme.spacing(0.5, 0),
39+
40+
'&:last-child': css({
41+
paddingBottom: 0,
42+
}),
43+
}),
44+
};
45+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { css } from '@emotion/css';
2+
3+
import { GrafanaTheme2 } from '@grafana/data';
4+
import { Icon, useStyles2 } from '@grafana/ui';
5+
import { t } from 'app/core/internationalization';
6+
7+
import { ScopesDashboardsTree } from './ScopesDashboardsTree';
8+
import { OnFolderUpdate, SuggestedDashboardsFolder, SuggestedDashboardsFoldersMap } from './types';
9+
10+
export interface ScopesDashboardsTreeFolderItemProps {
11+
folder: SuggestedDashboardsFolder;
12+
folderPath: string[];
13+
folders: SuggestedDashboardsFoldersMap;
14+
onFolderUpdate: OnFolderUpdate;
15+
}
16+
17+
export function ScopesDashboardsTreeFolderItem({
18+
folder,
19+
folderPath,
20+
folders,
21+
onFolderUpdate,
22+
}: ScopesDashboardsTreeFolderItemProps) {
23+
const styles = useStyles2(getStyles);
24+
25+
return (
26+
<div className={styles.container} role="treeitem" aria-selected={folder.isExpanded}>
27+
<button
28+
className={styles.expand}
29+
data-testid={`scopes-dashboards-${folder.title}-expand`}
30+
aria-label={
31+
folder.isExpanded ? t('scopes.dashboards.collapse', 'Collapse') : t('scopes.dashboards.expand', 'Expand')
32+
}
33+
onClick={() => {
34+
onFolderUpdate(folderPath, !folder.isExpanded);
35+
}}
36+
>
37+
<Icon name={!folder.isExpanded ? 'angle-right' : 'angle-down'} />
38+
39+
{folder.title}
40+
</button>
41+
42+
{folder.isExpanded && (
43+
<div className={styles.children}>
44+
<ScopesDashboardsTree folders={folders} folderPath={folderPath} onFolderUpdate={onFolderUpdate} />
45+
</div>
46+
)}
47+
</div>
48+
);
49+
}
50+
51+
const getStyles = (theme: GrafanaTheme2) => {
52+
return {
53+
container: css({
54+
display: 'flex',
55+
flexDirection: 'column',
56+
padding: theme.spacing(0.5, 0),
57+
}),
58+
expand: css({
59+
alignItems: 'center',
60+
background: 'none',
61+
border: 0,
62+
display: 'flex',
63+
gap: theme.spacing(1),
64+
margin: 0,
65+
padding: 0,
66+
}),
67+
children: css({
68+
paddingLeft: theme.spacing(4),
69+
}),
70+
};
71+
};

0 commit comments

Comments
 (0)
Failed to load comments.