mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Scopes: Group suggested dashboards (#92212)
This commit is contained in:
parent
1dd830b9f1
commit
605bc811d2
@ -2,6 +2,7 @@ export interface ScopeDashboardBindingSpec {
|
|||||||
dashboard: string;
|
dashboard: string;
|
||||||
dashboardTitle: string;
|
dashboardTitle: string;
|
||||||
scope: string;
|
scope: string;
|
||||||
|
groups?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Use Resource from apiserver when we export the types
|
// TODO: Use Resource from apiserver when we export the types
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import { isEqual } from 'lodash';
|
||||||
|
|
||||||
import { SceneObjectBase, SceneObjectState } from '@grafana/scenes';
|
import { SceneObjectBase, SceneObjectState } from '@grafana/scenes';
|
||||||
|
|
||||||
import { scopesSelectorScene } from './instance';
|
import { scopesSelectorScene } from './instance';
|
||||||
@ -20,7 +22,7 @@ export class ScopesFacade extends SceneObjectBase<ScopesFacadeState> {
|
|||||||
|
|
||||||
this._subs.add(
|
this._subs.add(
|
||||||
scopesSelectorScene?.subscribeToState((newState, prevState) => {
|
scopesSelectorScene?.subscribeToState((newState, prevState) => {
|
||||||
if (!newState.isLoadingScopes && (prevState.isLoadingScopes || newState.scopes !== prevState.scopes)) {
|
if (!newState.isLoadingScopes && (prevState.isLoadingScopes || !isEqual(newState.scopes, prevState.scopes))) {
|
||||||
this.state.handler?.(this);
|
this.state.handler?.(this);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
@ -1,23 +1,27 @@
|
|||||||
import { css, cx } from '@emotion/css';
|
import { css, cx } from '@emotion/css';
|
||||||
import { isEqual } from 'lodash';
|
import { isEqual } from 'lodash';
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
|
|
||||||
import { GrafanaTheme2, urlUtil } from '@grafana/data';
|
import { GrafanaTheme2, ScopeDashboardBinding } from '@grafana/data';
|
||||||
import { SceneComponentProps, SceneObjectBase, SceneObjectRef, SceneObjectState } from '@grafana/scenes';
|
import { SceneComponentProps, SceneObjectBase, SceneObjectRef, SceneObjectState } from '@grafana/scenes';
|
||||||
import { Button, CustomScrollbar, FilterInput, LoadingPlaceholder, useStyles2 } from '@grafana/ui';
|
import { Button, CustomScrollbar, LoadingPlaceholder, useStyles2 } from '@grafana/ui';
|
||||||
import { useQueryParams } from 'app/core/hooks/useQueryParams';
|
|
||||||
import { t, Trans } from 'app/core/internationalization';
|
import { t, Trans } from 'app/core/internationalization';
|
||||||
|
|
||||||
|
import { ScopesDashboardsTree } from './ScopesDashboardsTree';
|
||||||
|
import { ScopesDashboardsTreeSearch } from './ScopesDashboardsTreeSearch';
|
||||||
import { ScopesSelectorScene } from './ScopesSelectorScene';
|
import { ScopesSelectorScene } from './ScopesSelectorScene';
|
||||||
import { fetchSuggestedDashboards } from './api';
|
import { fetchDashboards } from './api';
|
||||||
import { DASHBOARDS_OPENED_KEY } from './const';
|
import { DASHBOARDS_OPENED_KEY } from './const';
|
||||||
import { SuggestedDashboard } from './types';
|
import { SuggestedDashboardsFoldersMap } from './types';
|
||||||
import { getScopeNamesFromSelectedScopes } from './utils';
|
import { filterFolders, getScopeNamesFromSelectedScopes, groupDashboards } from './utils';
|
||||||
|
|
||||||
export interface ScopesDashboardsSceneState extends SceneObjectState {
|
export interface ScopesDashboardsSceneState extends SceneObjectState {
|
||||||
selector: SceneObjectRef<ScopesSelectorScene> | null;
|
selector: SceneObjectRef<ScopesSelectorScene> | null;
|
||||||
dashboards: SuggestedDashboard[];
|
// by keeping a track of the raw response, it's much easier to check if we got any dashboards for the currently selected scopes
|
||||||
filteredDashboards: SuggestedDashboard[];
|
dashboards: ScopeDashboardBinding[];
|
||||||
|
// this is a grouping in folders of the `dashboards` property. it is used for filtering the dashboards and folders when the search query changes
|
||||||
|
folders: SuggestedDashboardsFoldersMap;
|
||||||
|
// a filtered version of the `folders` property. this prevents a lot of unnecessary parsings in React renders
|
||||||
|
filteredFolders: SuggestedDashboardsFoldersMap;
|
||||||
forScopeNames: string[];
|
forScopeNames: string[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isPanelOpened: boolean;
|
isPanelOpened: boolean;
|
||||||
@ -28,7 +32,8 @@ export interface ScopesDashboardsSceneState extends SceneObjectState {
|
|||||||
|
|
||||||
export const getInitialDashboardsState: () => Omit<ScopesDashboardsSceneState, 'selector'> = () => ({
|
export const getInitialDashboardsState: () => Omit<ScopesDashboardsSceneState, 'selector'> = () => ({
|
||||||
dashboards: [],
|
dashboards: [],
|
||||||
filteredDashboards: [],
|
folders: {},
|
||||||
|
filteredFolders: {},
|
||||||
forScopeNames: [],
|
forScopeNames: [],
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
isPanelOpened: localStorage.getItem(DASHBOARDS_OPENED_KEY) === 'true',
|
isPanelOpened: localStorage.getItem(DASHBOARDS_OPENED_KEY) === 'true',
|
||||||
@ -80,7 +85,8 @@ export class ScopesDashboardsScene extends SceneObjectBase<ScopesDashboardsScene
|
|||||||
if (scopeNames.length === 0) {
|
if (scopeNames.length === 0) {
|
||||||
return this.setState({
|
return this.setState({
|
||||||
dashboards: [],
|
dashboards: [],
|
||||||
filteredDashboards: [],
|
folders: {},
|
||||||
|
filteredFolders: {},
|
||||||
forScopeNames: [],
|
forScopeNames: [],
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
scopesSelected: false,
|
scopesSelected: false,
|
||||||
@ -89,11 +95,14 @@ export class ScopesDashboardsScene extends SceneObjectBase<ScopesDashboardsScene
|
|||||||
|
|
||||||
this.setState({ isLoading: true });
|
this.setState({ isLoading: true });
|
||||||
|
|
||||||
const dashboards = await fetchSuggestedDashboards(scopeNames);
|
const dashboards = await fetchDashboards(scopeNames);
|
||||||
|
const folders = groupDashboards(dashboards);
|
||||||
|
const filteredFolders = filterFolders(folders, this.state.searchQuery);
|
||||||
|
|
||||||
this.setState({
|
this.setState({
|
||||||
dashboards,
|
dashboards,
|
||||||
filteredDashboards: this.filterDashboards(dashboards, this.state.searchQuery),
|
folders,
|
||||||
|
filteredFolders,
|
||||||
forScopeNames: scopeNames,
|
forScopeNames: scopeNames,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
scopesSelected: scopeNames.length > 0,
|
scopesSelected: scopeNames.length > 0,
|
||||||
@ -101,14 +110,35 @@ export class ScopesDashboardsScene extends SceneObjectBase<ScopesDashboardsScene
|
|||||||
}
|
}
|
||||||
|
|
||||||
public changeSearchQuery(searchQuery: string) {
|
public changeSearchQuery(searchQuery: string) {
|
||||||
|
searchQuery = searchQuery ?? '';
|
||||||
|
|
||||||
this.setState({
|
this.setState({
|
||||||
filteredDashboards: searchQuery
|
filteredFolders: filterFolders(this.state.folders, searchQuery),
|
||||||
? this.filterDashboards(this.state.dashboards, searchQuery)
|
searchQuery,
|
||||||
: this.state.dashboards,
|
|
||||||
searchQuery: searchQuery ?? '',
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public updateFolder(path: string[], isExpanded: boolean) {
|
||||||
|
let folders = { ...this.state.folders };
|
||||||
|
let filteredFolders = { ...this.state.filteredFolders };
|
||||||
|
let currentLevelFolders: SuggestedDashboardsFoldersMap = folders;
|
||||||
|
let currentLevelFilteredFolders: SuggestedDashboardsFoldersMap = 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];
|
||||||
|
|
||||||
|
currentFolder.isExpanded = isExpanded;
|
||||||
|
currentFilteredFolder.isExpanded = isExpanded;
|
||||||
|
|
||||||
|
this.setState({ folders, filteredFolders });
|
||||||
|
}
|
||||||
|
|
||||||
public togglePanel() {
|
public togglePanel() {
|
||||||
if (this.state.isPanelOpened) {
|
if (this.state.isPanelOpened) {
|
||||||
this.closePanel();
|
this.closePanel();
|
||||||
@ -135,20 +165,13 @@ export class ScopesDashboardsScene extends SceneObjectBase<ScopesDashboardsScene
|
|||||||
public disable() {
|
public disable() {
|
||||||
this.setState({ isEnabled: false });
|
this.setState({ isEnabled: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
private filterDashboards(dashboards: SuggestedDashboard[], searchQuery: string): SuggestedDashboard[] {
|
|
||||||
const lowerCasedSearchQuery = searchQuery.toLowerCase();
|
|
||||||
|
|
||||||
return dashboards.filter(({ dashboardTitle }) => dashboardTitle.toLowerCase().includes(lowerCasedSearchQuery));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ScopesDashboardsSceneRenderer({ model }: SceneComponentProps<ScopesDashboardsScene>) {
|
export function ScopesDashboardsSceneRenderer({ model }: SceneComponentProps<ScopesDashboardsScene>) {
|
||||||
const { dashboards, filteredDashboards, isLoading, isPanelOpened, isEnabled, searchQuery, scopesSelected } =
|
const { dashboards, filteredFolders, isLoading, isPanelOpened, isEnabled, searchQuery, scopesSelected } =
|
||||||
model.useState();
|
model.useState();
|
||||||
const styles = useStyles2(getStyles);
|
|
||||||
|
|
||||||
const [queryParams] = useQueryParams();
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
if (!isEnabled || !isPanelOpened) {
|
if (!isEnabled || !isPanelOpened) {
|
||||||
return null;
|
return null;
|
||||||
@ -178,15 +201,11 @@ export function ScopesDashboardsSceneRenderer({ model }: SceneComponentProps<Sco
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container} data-testid="scopes-dashboards-container">
|
<div className={styles.container} data-testid="scopes-dashboards-container">
|
||||||
<div className={styles.searchInputContainer}>
|
<ScopesDashboardsTreeSearch
|
||||||
<FilterInput
|
disabled={isLoading}
|
||||||
disabled={isLoading}
|
query={searchQuery}
|
||||||
placeholder={t('scopes.dashboards.search', 'Search')}
|
onChange={(value) => model.changeSearchQuery(value)}
|
||||||
value={searchQuery}
|
/>
|
||||||
data-testid="scopes-dashboards-search"
|
|
||||||
onChange={(value) => model.changeSearchQuery(value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<LoadingPlaceholder
|
<LoadingPlaceholder
|
||||||
@ -194,18 +213,13 @@ export function ScopesDashboardsSceneRenderer({ model }: SceneComponentProps<Sco
|
|||||||
text={t('scopes.dashboards.loading', 'Loading dashboards')}
|
text={t('scopes.dashboards.loading', 'Loading dashboards')}
|
||||||
data-testid="scopes-dashboards-loading"
|
data-testid="scopes-dashboards-loading"
|
||||||
/>
|
/>
|
||||||
) : filteredDashboards.length > 0 ? (
|
) : filteredFolders[''] ? (
|
||||||
<CustomScrollbar>
|
<CustomScrollbar>
|
||||||
{filteredDashboards.map(({ dashboard, dashboardTitle }) => (
|
<ScopesDashboardsTree
|
||||||
<Link
|
folders={filteredFolders}
|
||||||
key={dashboard}
|
folderPath={['']}
|
||||||
to={urlUtil.renderUrl(`/d/${dashboard}/`, queryParams)}
|
onFolderUpdate={(path, isExpanded) => model.updateFolder(path, isExpanded)}
|
||||||
className={styles.dashboardItem}
|
/>
|
||||||
data-testid={`scopes-dashboards-${dashboard}`}
|
|
||||||
>
|
|
||||||
{dashboardTitle}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</CustomScrollbar>
|
</CustomScrollbar>
|
||||||
) : (
|
) : (
|
||||||
<p className={styles.noResultsContainer} data-testid="scopes-dashboards-notFoundForFilter">
|
<p className={styles.noResultsContainer} data-testid="scopes-dashboards-notFoundForFilter">
|
||||||
@ -246,19 +260,8 @@ const getStyles = (theme: GrafanaTheme2) => {
|
|||||||
margin: 0,
|
margin: 0,
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
}),
|
}),
|
||||||
searchInputContainer: css({
|
|
||||||
flex: '0 1 auto',
|
|
||||||
}),
|
|
||||||
loadingIndicator: css({
|
loadingIndicator: css({
|
||||||
alignSelf: 'center',
|
alignSelf: 'center',
|
||||||
}),
|
}),
|
||||||
dashboardItem: css({
|
|
||||||
padding: theme.spacing(1, 0),
|
|
||||||
borderBottom: `1px solid ${theme.colors.border.weak}`,
|
|
||||||
|
|
||||||
'& :is(:first-child)': {
|
|
||||||
paddingTop: 0,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
32
public/app/features/scopes/internal/ScopesDashboardsTree.tsx
Normal file
32
public/app/features/scopes/internal/ScopesDashboardsTree.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { ScopesDashboardsTreeDashboardItem } from './ScopesDashboardsTreeDashboardItem';
|
||||||
|
import { ScopesDashboardsTreeFolderItem } from './ScopesDashboardsTreeFolderItem';
|
||||||
|
import { OnFolderUpdate, SuggestedDashboardsFoldersMap } from './types';
|
||||||
|
|
||||||
|
export interface ScopesDashboardsTreeProps {
|
||||||
|
folders: SuggestedDashboardsFoldersMap;
|
||||||
|
folderPath: string[];
|
||||||
|
onFolderUpdate: OnFolderUpdate;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScopesDashboardsTree({ folders, folderPath, onFolderUpdate }: ScopesDashboardsTreeProps) {
|
||||||
|
const folderId = folderPath[folderPath.length - 1];
|
||||||
|
const folder = folders[folderId];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div role="tree">
|
||||||
|
{Object.entries(folder.folders).map(([subFolderId, subFolder]) => (
|
||||||
|
<ScopesDashboardsTreeFolderItem
|
||||||
|
key={subFolderId}
|
||||||
|
folder={subFolder}
|
||||||
|
folders={folder.folders}
|
||||||
|
folderPath={[...folderPath, subFolderId]}
|
||||||
|
onFolderUpdate={onFolderUpdate}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{Object.values(folder.dashboards).map((dashboard) => (
|
||||||
|
<ScopesDashboardsTreeDashboardItem key={dashboard.dashboard} dashboard={dashboard} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,45 @@
|
|||||||
|
import { css } from '@emotion/css';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { GrafanaTheme2, urlUtil } from '@grafana/data';
|
||||||
|
import { Icon, useStyles2 } from '@grafana/ui';
|
||||||
|
import { useQueryParams } from 'app/core/hooks/useQueryParams';
|
||||||
|
|
||||||
|
import { SuggestedDashboard } from './types';
|
||||||
|
|
||||||
|
export interface ScopesDashboardsTreeDashboardItemProps {
|
||||||
|
dashboard: SuggestedDashboard;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScopesDashboardsTreeDashboardItem({ dashboard }: ScopesDashboardsTreeDashboardItemProps) {
|
||||||
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
|
const [queryParams] = useQueryParams();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={dashboard.dashboard}
|
||||||
|
to={urlUtil.renderUrl(`/d/${dashboard.dashboard}/`, queryParams)}
|
||||||
|
className={styles.container}
|
||||||
|
data-testid={`scopes-dashboards-${dashboard.dashboard}`}
|
||||||
|
role="treeitem"
|
||||||
|
>
|
||||||
|
<Icon name="apps" /> {dashboard.dashboardTitle}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStyles = (theme: GrafanaTheme2) => {
|
||||||
|
return {
|
||||||
|
container: css({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: theme.spacing(1),
|
||||||
|
padding: theme.spacing(0.5, 0),
|
||||||
|
|
||||||
|
'&:last-child': css({
|
||||||
|
paddingBottom: 0,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
};
|
@ -0,0 +1,71 @@
|
|||||||
|
import { css } from '@emotion/css';
|
||||||
|
|
||||||
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
|
import { Icon, useStyles2 } from '@grafana/ui';
|
||||||
|
import { t } from 'app/core/internationalization';
|
||||||
|
|
||||||
|
import { ScopesDashboardsTree } from './ScopesDashboardsTree';
|
||||||
|
import { OnFolderUpdate, SuggestedDashboardsFolder, SuggestedDashboardsFoldersMap } from './types';
|
||||||
|
|
||||||
|
export interface ScopesDashboardsTreeFolderItemProps {
|
||||||
|
folder: SuggestedDashboardsFolder;
|
||||||
|
folderPath: string[];
|
||||||
|
folders: SuggestedDashboardsFoldersMap;
|
||||||
|
onFolderUpdate: OnFolderUpdate;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScopesDashboardsTreeFolderItem({
|
||||||
|
folder,
|
||||||
|
folderPath,
|
||||||
|
folders,
|
||||||
|
onFolderUpdate,
|
||||||
|
}: ScopesDashboardsTreeFolderItemProps) {
|
||||||
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container} role="treeitem" aria-selected={folder.isExpanded}>
|
||||||
|
<button
|
||||||
|
className={styles.expand}
|
||||||
|
data-testid={`scopes-dashboards-${folder.title}-expand`}
|
||||||
|
aria-label={
|
||||||
|
folder.isExpanded ? t('scopes.dashboards.collapse', 'Collapse') : t('scopes.dashboards.expand', 'Expand')
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
onFolderUpdate(folderPath, !folder.isExpanded);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name={!folder.isExpanded ? 'angle-right' : 'angle-down'} />
|
||||||
|
|
||||||
|
{folder.title}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{folder.isExpanded && (
|
||||||
|
<div className={styles.children}>
|
||||||
|
<ScopesDashboardsTree folders={folders} folderPath={folderPath} onFolderUpdate={onFolderUpdate} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStyles = (theme: GrafanaTheme2) => {
|
||||||
|
return {
|
||||||
|
container: css({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
padding: theme.spacing(0.5, 0),
|
||||||
|
}),
|
||||||
|
expand: css({
|
||||||
|
alignItems: 'center',
|
||||||
|
background: 'none',
|
||||||
|
border: 0,
|
||||||
|
display: 'flex',
|
||||||
|
gap: theme.spacing(1),
|
||||||
|
margin: 0,
|
||||||
|
padding: 0,
|
||||||
|
}),
|
||||||
|
children: css({
|
||||||
|
paddingLeft: theme.spacing(4),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
};
|
@ -0,0 +1,55 @@
|
|||||||
|
import { css } from '@emotion/css';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useDebounce } from 'react-use';
|
||||||
|
|
||||||
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
|
import { FilterInput, useStyles2 } from '@grafana/ui';
|
||||||
|
import { t } from 'app/core/internationalization';
|
||||||
|
|
||||||
|
export interface ScopesDashboardsTreeSearchProps {
|
||||||
|
disabled: boolean;
|
||||||
|
query: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScopesDashboardsTreeSearch({ disabled, query, onChange }: ScopesDashboardsTreeSearchProps) {
|
||||||
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
|
const [inputState, setInputState] = useState<{ value: string; isDirty: boolean }>({ value: query, isDirty: false });
|
||||||
|
|
||||||
|
const [getDebounceState] = useDebounce(
|
||||||
|
() => {
|
||||||
|
if (inputState.isDirty) {
|
||||||
|
onChange(inputState.value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
500,
|
||||||
|
[inputState.isDirty, inputState.value]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if ((getDebounceState() || !inputState.isDirty) && inputState.value !== query) {
|
||||||
|
setInputState({ value: query, isDirty: false });
|
||||||
|
}
|
||||||
|
}, [getDebounceState, inputState, query]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<FilterInput
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder={t('scopes.dashboards.search', 'Search')}
|
||||||
|
value={inputState.value}
|
||||||
|
data-testid="scopes-dashboards-search"
|
||||||
|
onChange={(value) => setInputState({ value, isDirty: true })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStyles = (theme: GrafanaTheme2) => {
|
||||||
|
return {
|
||||||
|
container: css({
|
||||||
|
flex: '0 1 auto',
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
};
|
@ -2,7 +2,7 @@ import { Scope, ScopeDashboardBinding, ScopeNode, ScopeSpec } from '@grafana/dat
|
|||||||
import { config, getBackendSrv } from '@grafana/runtime';
|
import { config, getBackendSrv } from '@grafana/runtime';
|
||||||
import { ScopedResourceClient } from 'app/features/apiserver/client';
|
import { ScopedResourceClient } from 'app/features/apiserver/client';
|
||||||
|
|
||||||
import { NodeReason, NodesMap, SelectedScope, SuggestedDashboard, TreeScope } from './types';
|
import { NodeReason, NodesMap, SelectedScope, TreeScope } from './types';
|
||||||
import { getBasicScope, mergeScopes } from './utils';
|
import { getBasicScope, mergeScopes } from './utils';
|
||||||
|
|
||||||
const group = 'scope.grafana.app';
|
const group = 'scope.grafana.app';
|
||||||
@ -98,23 +98,3 @@ export async function fetchDashboards(scopeNames: string[]): Promise<ScopeDashbo
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchSuggestedDashboards(scopeNames: string[]): Promise<SuggestedDashboard[]> {
|
|
||||||
const items = await fetchDashboards(scopeNames);
|
|
||||||
|
|
||||||
return Object.values(
|
|
||||||
items.reduce<Record<string, SuggestedDashboard>>((acc, item) => {
|
|
||||||
if (!acc[item.spec.dashboard]) {
|
|
||||||
acc[item.spec.dashboard] = {
|
|
||||||
dashboard: item.spec.dashboard,
|
|
||||||
dashboardTitle: item.spec.dashboardTitle,
|
|
||||||
items: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
acc[item.spec.dashboard].items.push(item);
|
|
||||||
|
|
||||||
return acc;
|
|
||||||
}, {})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
@ -33,5 +33,16 @@ export interface SuggestedDashboard {
|
|||||||
items: ScopeDashboardBinding[];
|
items: ScopeDashboardBinding[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SuggestedDashboardsFolder {
|
||||||
|
title: string;
|
||||||
|
isExpanded: boolean;
|
||||||
|
folders: SuggestedDashboardsFoldersMap;
|
||||||
|
dashboards: SuggestedDashboardsMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SuggestedDashboardsMap = Record<string, SuggestedDashboard>;
|
||||||
|
export type SuggestedDashboardsFoldersMap = Record<string, SuggestedDashboardsFolder>;
|
||||||
|
|
||||||
export type OnNodeUpdate = (path: string[], isExpanded: boolean, query: string) => void;
|
export type OnNodeUpdate = (path: string[], isExpanded: boolean, query: string) => void;
|
||||||
export type OnNodeSelectToggle = (path: string[]) => void;
|
export type OnNodeSelectToggle = (path: string[]) => void;
|
||||||
|
export type OnFolderUpdate = (path: string[], isExpanded: boolean) => void;
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { Scope } from '@grafana/data';
|
import { Scope, ScopeDashboardBinding } from '@grafana/data';
|
||||||
|
|
||||||
import { SelectedScope, TreeScope } from './types';
|
import { SelectedScope, SuggestedDashboardsFoldersMap, TreeScope } from './types';
|
||||||
|
|
||||||
export function getBasicScope(name: string): Scope {
|
export function getBasicScope(name: string): Scope {
|
||||||
return {
|
return {
|
||||||
@ -43,3 +43,82 @@ export function getScopesFromSelectedScopes(scopes: SelectedScope[]): Scope[] {
|
|||||||
export function getScopeNamesFromSelectedScopes(scopes: SelectedScope[]): string[] {
|
export function getScopeNamesFromSelectedScopes(scopes: SelectedScope[]): string[] {
|
||||||
return scopes.map(({ scope }) => scope.metadata.name);
|
return scopes.map(({ scope }) => scope.metadata.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function groupDashboards(dashboards: ScopeDashboardBinding[]): SuggestedDashboardsFoldersMap {
|
||||||
|
return dashboards.reduce<SuggestedDashboardsFoldersMap>(
|
||||||
|
(acc, dashboard) => {
|
||||||
|
const rootNode = acc[''];
|
||||||
|
const groups = dashboard.spec.groups ?? [];
|
||||||
|
|
||||||
|
groups.forEach((group) => {
|
||||||
|
if (group && !rootNode.folders[group]) {
|
||||||
|
rootNode.folders[group] = {
|
||||||
|
title: group,
|
||||||
|
isExpanded: false,
|
||||||
|
folders: {},
|
||||||
|
dashboards: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const targets =
|
||||||
|
groups.length > 0
|
||||||
|
? groups.map((group) => (group === '' ? rootNode.dashboards : rootNode.folders[group].dashboards))
|
||||||
|
: [rootNode.dashboards];
|
||||||
|
|
||||||
|
targets.forEach((target) => {
|
||||||
|
if (!target[dashboard.spec.dashboard]) {
|
||||||
|
target[dashboard.spec.dashboard] = {
|
||||||
|
dashboard: dashboard.spec.dashboard,
|
||||||
|
dashboardTitle: dashboard.spec.dashboardTitle,
|
||||||
|
items: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
target[dashboard.spec.dashboard].items.push(dashboard);
|
||||||
|
});
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'': {
|
||||||
|
title: '',
|
||||||
|
isExpanded: true,
|
||||||
|
folders: {},
|
||||||
|
dashboards: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterFolders(folders: SuggestedDashboardsFoldersMap, query: string): SuggestedDashboardsFoldersMap {
|
||||||
|
query = (query ?? '').toLowerCase();
|
||||||
|
|
||||||
|
return Object.entries(folders).reduce<SuggestedDashboardsFoldersMap>((acc, [folderId, folder]) => {
|
||||||
|
// If folder matches the query, we show everything inside
|
||||||
|
if (folder.title.toLowerCase().includes(query)) {
|
||||||
|
acc[folderId] = {
|
||||||
|
...folder,
|
||||||
|
isExpanded: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredFolders = filterFolders(folder.folders, query);
|
||||||
|
const filteredDashboards = Object.entries(folder.dashboards).filter(([_, dashboard]) =>
|
||||||
|
dashboard.dashboardTitle.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (Object.keys(filteredFolders).length > 0 || filteredDashboards.length > 0) {
|
||||||
|
acc[folderId] = {
|
||||||
|
...folder,
|
||||||
|
isExpanded: true,
|
||||||
|
folders: filteredFolders,
|
||||||
|
dashboards: Object.fromEntries(filteredDashboards),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
@ -1,634 +0,0 @@
|
|||||||
import { act, cleanup, waitFor } from '@testing-library/react';
|
|
||||||
import userEvents from '@testing-library/user-event';
|
|
||||||
|
|
||||||
import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
|
|
||||||
import { config, locationService, setPluginImportUtils } from '@grafana/runtime';
|
|
||||||
import { sceneGraph } from '@grafana/scenes';
|
|
||||||
import { getDashboardAPI, setDashboardAPI } from 'app/features/dashboard/api/dashboard_api';
|
|
||||||
import { DashboardScene } from 'app/features/dashboard-scene/scene/DashboardScene';
|
|
||||||
|
|
||||||
import { initializeScopes, scopesDashboardsScene, scopesSelectorScene } from './instance';
|
|
||||||
import {
|
|
||||||
buildTestScene,
|
|
||||||
fetchNodesSpy,
|
|
||||||
fetchScopeSpy,
|
|
||||||
fetchSelectedScopesSpy,
|
|
||||||
fetchSuggestedDashboardsSpy,
|
|
||||||
getDashboard,
|
|
||||||
getDashboardsExpand,
|
|
||||||
getDashboardsSearch,
|
|
||||||
getMock,
|
|
||||||
getNotFoundForFilter,
|
|
||||||
getNotFoundForFilterClear,
|
|
||||||
getNotFoundForScope,
|
|
||||||
getNotFoundNoScopes,
|
|
||||||
getPersistedApplicationsSlothPictureFactorySelect,
|
|
||||||
getPersistedApplicationsSlothPictureFactoryTitle,
|
|
||||||
getPersistedApplicationsSlothVoteTrackerTitle,
|
|
||||||
getResultApplicationsClustersExpand,
|
|
||||||
getResultApplicationsClustersSelect,
|
|
||||||
getResultApplicationsClustersSlothClusterNorthSelect,
|
|
||||||
getResultApplicationsClustersSlothClusterSouthSelect,
|
|
||||||
getResultApplicationsExpand,
|
|
||||||
getResultApplicationsSlothPictureFactorySelect,
|
|
||||||
getResultApplicationsSlothPictureFactoryTitle,
|
|
||||||
getResultApplicationsSlothVoteTrackerSelect,
|
|
||||||
getResultApplicationsSlothVoteTrackerTitle,
|
|
||||||
getResultClustersExpand,
|
|
||||||
getResultClustersSelect,
|
|
||||||
getResultClustersSlothClusterEastRadio,
|
|
||||||
getResultClustersSlothClusterNorthRadio,
|
|
||||||
getResultClustersSlothClusterSouthRadio,
|
|
||||||
getSelectorApply,
|
|
||||||
getSelectorCancel,
|
|
||||||
getSelectorInput,
|
|
||||||
getTreeHeadline,
|
|
||||||
getTreeSearch,
|
|
||||||
mocksScopes,
|
|
||||||
queryAllDashboard,
|
|
||||||
queryDashboard,
|
|
||||||
queryDashboardsContainer,
|
|
||||||
queryDashboardsSearch,
|
|
||||||
queryPersistedApplicationsSlothPictureFactoryTitle,
|
|
||||||
queryPersistedApplicationsSlothVoteTrackerTitle,
|
|
||||||
queryResultApplicationsClustersTitle,
|
|
||||||
queryResultApplicationsSlothPictureFactoryTitle,
|
|
||||||
queryResultApplicationsSlothVoteTrackerTitle,
|
|
||||||
querySelectorApply,
|
|
||||||
renderDashboard,
|
|
||||||
resetScenes,
|
|
||||||
} from './testUtils';
|
|
||||||
import { getClosestScopesFacade } from './utils';
|
|
||||||
|
|
||||||
jest.mock('@grafana/runtime', () => ({
|
|
||||||
__esModule: true,
|
|
||||||
...jest.requireActual('@grafana/runtime'),
|
|
||||||
useChromeHeaderHeight: jest.fn(),
|
|
||||||
getBackendSrv: () => ({
|
|
||||||
get: getMock,
|
|
||||||
}),
|
|
||||||
usePluginLinkExtensions: jest.fn().mockReturnValue({ extensions: [] }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const panelPlugin = getPanelPlugin({
|
|
||||||
id: 'table',
|
|
||||||
skipDataQuery: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
config.panels['table'] = panelPlugin.meta;
|
|
||||||
|
|
||||||
setPluginImportUtils({
|
|
||||||
importPanelPlugin: (id: string) => Promise.resolve(panelPlugin),
|
|
||||||
getPanelPluginFromCache: (id: string) => undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Scopes', () => {
|
|
||||||
describe('Feature flag off', () => {
|
|
||||||
beforeAll(() => {
|
|
||||||
config.featureToggles.scopeFilters = false;
|
|
||||||
config.featureToggles.groupByVariable = true;
|
|
||||||
|
|
||||||
initializeScopes();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Does not initialize', () => {
|
|
||||||
const dashboardScene = buildTestScene();
|
|
||||||
dashboardScene.activate();
|
|
||||||
expect(scopesSelectorScene).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Feature flag on', () => {
|
|
||||||
let dashboardScene: DashboardScene;
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
config.featureToggles.scopeFilters = true;
|
|
||||||
config.featureToggles.groupByVariable = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.spyOn(console, 'error').mockImplementation(jest.fn());
|
|
||||||
|
|
||||||
fetchNodesSpy.mockClear();
|
|
||||||
fetchScopeSpy.mockClear();
|
|
||||||
fetchSelectedScopesSpy.mockClear();
|
|
||||||
fetchSuggestedDashboardsSpy.mockClear();
|
|
||||||
getMock.mockClear();
|
|
||||||
|
|
||||||
initializeScopes();
|
|
||||||
|
|
||||||
dashboardScene = buildTestScene();
|
|
||||||
|
|
||||||
renderDashboard(dashboardScene);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
resetScenes();
|
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Tree', () => {
|
|
||||||
it('Navigates through scopes nodes', async () => {
|
|
||||||
await userEvents.click(getSelectorInput());
|
|
||||||
await userEvents.click(getResultApplicationsExpand());
|
|
||||||
await userEvents.click(getResultApplicationsClustersExpand());
|
|
||||||
await userEvents.click(getResultApplicationsExpand());
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Fetches scope details on select', async () => {
|
|
||||||
await userEvents.click(getSelectorInput());
|
|
||||||
await userEvents.click(getResultApplicationsExpand());
|
|
||||||
await userEvents.click(getResultApplicationsSlothVoteTrackerSelect());
|
|
||||||
await waitFor(() => expect(fetchScopeSpy).toHaveBeenCalledTimes(1));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Selects the proper scopes', async () => {
|
|
||||||
await act(async () =>
|
|
||||||
scopesSelectorScene?.updateScopes([
|
|
||||||
{ scopeName: 'slothPictureFactory', path: [] },
|
|
||||||
{ scopeName: 'slothVoteTracker', path: [] },
|
|
||||||
])
|
|
||||||
);
|
|
||||||
await userEvents.click(getSelectorInput());
|
|
||||||
await userEvents.click(getResultApplicationsExpand());
|
|
||||||
expect(getResultApplicationsSlothVoteTrackerSelect()).toBeChecked();
|
|
||||||
expect(getResultApplicationsSlothPictureFactorySelect()).toBeChecked();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Can select scopes from same level', async () => {
|
|
||||||
await userEvents.click(getSelectorInput());
|
|
||||||
await userEvents.click(getResultApplicationsExpand());
|
|
||||||
await userEvents.click(getResultApplicationsSlothVoteTrackerSelect());
|
|
||||||
await userEvents.click(getResultApplicationsSlothPictureFactorySelect());
|
|
||||||
await userEvents.click(getResultApplicationsClustersSelect());
|
|
||||||
await userEvents.click(getSelectorApply());
|
|
||||||
expect(getSelectorInput().value).toBe('slothVoteTracker, slothPictureFactory, Cluster Index Helper');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Can select a node from an inner level', async () => {
|
|
||||||
await userEvents.click(getSelectorInput());
|
|
||||||
await userEvents.click(getResultApplicationsExpand());
|
|
||||||
await userEvents.click(getResultApplicationsSlothVoteTrackerSelect());
|
|
||||||
await userEvents.click(getResultApplicationsClustersExpand());
|
|
||||||
await userEvents.click(getResultApplicationsClustersSlothClusterNorthSelect());
|
|
||||||
await userEvents.click(getSelectorApply());
|
|
||||||
expect(getSelectorInput().value).toBe('slothClusterNorth');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Can select a node from an upper level', async () => {
|
|
||||||
await userEvents.click(getSelectorInput());
|
|
||||||
await userEvents.click(getResultApplicationsExpand());
|
|
||||||
await userEvents.click(getResultApplicationsSlothVoteTrackerSelect());
|
|
||||||
await userEvents.click(getResultApplicationsExpand());
|
|
||||||
await userEvents.click(getResultClustersSelect());
|
|
||||||
await userEvents.click(getSelectorApply());
|
|
||||||
expect(getSelectorInput().value).toBe('Cluster Index Helper');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Respects only one select per container', async () => {
|
|
||||||
await userEvents.click(getSelectorInput());
|
|
||||||
await userEvents.click(getResultClustersExpand());
|
|
||||||
await userEvents.click(getResultClustersSlothClusterNorthRadio());
|
|
||||||
expect(getResultClustersSlothClusterNorthRadio().checked).toBe(true);
|
|
||||||
expect(getResultClustersSlothClusterSouthRadio().checked).toBe(false);
|
|
||||||
await userEvents.click(getResultClustersSlothClusterSouthRadio());
|
|
||||||
expect(getResultClustersSlothClusterNorthRadio().checked).toBe(false);
|
|
||||||
expect(getResultClustersSlothClusterSouthRadio().checked).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Search works', async () => {
|
|
||||||
await userEvents.click(getSelectorInput());
|
|
||||||
await userEvents.click(getResultApplicationsExpand());
|
|
||||||
await userEvents.type(getTreeSearch(), 'Clusters');
|
|
||||||
await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(3));
|
|
||||||
expect(queryResultApplicationsSlothPictureFactoryTitle()).not.toBeInTheDocument();
|
|
||||||
expect(queryResultApplicationsSlothVoteTrackerTitle()).not.toBeInTheDocument();
|
|
||||||
expect(getResultApplicationsClustersSelect()).toBeInTheDocument();
|
|
||||||
await userEvents.clear(getTreeSearch());
|
|
||||||
await userEvents.type(getTreeSearch(), 'sloth');
|
|
||||||
await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(4));
|
|
||||||
expect(getResultApplicationsSlothPictureFactoryTitle()).toBeInTheDocument();
|
|
||||||
expect(getResultApplicationsSlothVoteTrackerSelect()).toBeInTheDocument();
|
|
||||||
expect(queryResultApplicationsClustersTitle()).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Opens to a selected scope', async () => {
|
|
||||||
await userEvents.click(getSelectorInput());
|
|
||||||
await userEvents.click(getResultApplicationsExpand());
|
|
||||||
await userEvents.click(getResultApplicationsSlothPictureFactorySelect());
|
|
||||||
await userEvents.click(getResultApplicationsExpand());
|
|
||||||
await userEvents.click(getResultClustersExpand());
|
|
||||||
await userEvents.click(getSelectorApply());
|
|
||||||
await userEvents.click(getSelectorInput());
|
|
||||||
expect(queryResultApplicationsSlothPictureFactoryTitle()).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Persists a scope', async () => {
|
|
||||||
await userEvents.click(getSelectorInput());
|
|
||||||
await userEvents.click(getResultApplicationsExpand());
|
|
||||||
await userEvents.click(getResultApplicationsSlothPictureFactorySelect());
|
|
||||||
await userEvents.type(getTreeSearch(), 'slothVoteTracker');
|
|
||||||
await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(3));
|
|
||||||
expect(getPersistedApplicationsSlothPictureFactoryTitle()).toBeInTheDocument();
|
|
||||||
expect(queryPersistedApplicationsSlothVoteTrackerTitle()).not.toBeInTheDocument();
|
|
||||||
expect(queryResultApplicationsSlothPictureFactoryTitle()).not.toBeInTheDocument();
|
|
||||||
expect(getResultApplicationsSlothVoteTrackerTitle()).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Does not persist a retrieved scope', async () => {
|
|
||||||
await userEvents.click(getSelectorInput());
|
|
||||||
await userEvents.click(getResultApplicationsExpand());
|
|
||||||
await userEvents.click(getResultApplicationsSlothPictureFactorySelect());
|
|
||||||
await userEvents.type(getTreeSearch(), 'slothPictureFactory');
|
|
||||||
await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(3));
|
|
||||||
expect(queryPersistedApplicationsSlothPictureFactoryTitle()).not.toBeInTheDocument();
|
|
||||||
expect(getResultApplicationsSlothPictureFactoryTitle()).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Removes persisted nodes', async () => {
|
|
||||||
await userEvents.click(getSelectorInput());
|
|
||||||
await userEvents.click(getResultApplicationsExpand());
|
|
||||||
await userEvents.click(getResultApplicationsSlothPictureFactorySelect());
|
|
||||||
await userEvents.type(getTreeSearch(), 'slothVoteTracker');
|
|
||||||
await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(3));
|
|
||||||
await userEvents.clear(getTreeSearch());
|
|
||||||
await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(4));
|
|
||||||
expect(queryPersistedApplicationsSlothPictureFactoryTitle()).not.toBeInTheDocument();
|
|
||||||
expect(queryPersistedApplicationsSlothVoteTrackerTitle()).not.toBeInTheDocument();
|
|
||||||
expect(getResultApplicationsSlothPictureFactoryTitle()).toBeInTheDocument();
|
|
||||||
expect(getResultApplicationsSlothVoteTrackerTitle()).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Persists nodes from search', async () => {
|
|
||||||
await userEvents.click(getSelectorInput());
|
|
||||||
await userEvents.click(getResultApplicationsExpand());
|
|
||||||
await userEvents.type(getTreeSearch(), 'sloth');
|
|
||||||
await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(3));
|
|
||||||
await userEvents.click(getResultApplicationsSlothPictureFactorySelect());
|
|
||||||
await userEvents.click(getResultApplicationsSlothVoteTrackerSelect());
|
|
||||||
await userEvents.type(getTreeSearch(), 'slothunknown');
|
|
||||||
await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(4));
|
|
||||||
expect(getPersistedApplicationsSlothPictureFactoryTitle()).toBeInTheDocument();
|
|
||||||
expect(getPersistedApplicationsSlothVoteTrackerTitle()).toBeInTheDocument();
|
|
||||||
await userEvents.clear(getTreeSearch());
|
|
||||||
await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(5));
|
|
||||||
expect(getResultApplicationsSlothPictureFactoryTitle()).toBeInTheDocument();
|
|
||||||
expect(getResultApplicationsSlothVoteTrackerTitle()).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Selects a persisted scope', async () => {
|
|
||||||
await userEvents.click(getSelectorInput());
|
|
||||||
await userEvents.click(getResultApplicationsExpand());
|
|
||||||
await userEvents.click(getResultApplicationsSlothPictureFactorySelect());
|
|
||||||
await userEvents.type(getTreeSearch(), 'slothVoteTracker');
|
|
||||||
await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(3));
|
|
||||||
await userEvents.click(getResultApplicationsSlothVoteTrackerSelect());
|
|
||||||
await userEvents.click(getSelectorApply());
|
|
||||||
expect(getSelectorInput().value).toBe('slothPictureFactory, slothVoteTracker');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Deselects a persisted scope', async () => {
|
|
||||||
await userEvents.click(getSelectorInput());
|
|
||||||
await userEvents.click(getResultApplicationsExpand());
|
|
||||||
await userEvents.click(getResultApplicationsSlothPictureFactorySelect());
|
|
||||||
await userEvents.type(getTreeSearch(), 'slothVoteTracker');
|
|
||||||
await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(3));
|
|
||||||
await userEvents.click(getResultApplicationsSlothVoteTrackerSelect());
|
|
||||||
await userEvents.click(getSelectorApply());
|
|
||||||
expect(getSelectorInput().value).toBe('slothPictureFactory, slothVoteTracker');
|
|
||||||
await userEvents.click(getSelectorInput());
|
|
||||||
await userEvents.click(getPersistedApplicationsSlothPictureFactorySelect());
|
|
||||||
await userEvents.click(getSelectorApply());
|
|
||||||
expect(getSelectorInput().value).toBe('slothVoteTracker');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Shows the proper headline', async () => {
|
|
||||||
await userEvents.click(getSelectorInput());
|
|
||||||
expect(getTreeHeadline()).toHaveTextContent('Recommended');
|
|
||||||
await userEvents.type(getTreeSearch(), 'Applications');
|
|
||||||
await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(2));
|
|
||||||
expect(getTreeHeadline()).toHaveTextContent('Results');
|
|
||||||
await userEvents.type(getTreeSearch(), 'unknown');
|
|
||||||
await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(3));
|
|
||||||
expect(getTreeHeadline()).toHaveTextContent('No results found for your query');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Selector', () => {
|
|
||||||
it('Opens', async () => {
|
|
||||||
await userEvents.click(getSelectorInput());
|
|
||||||
expect(getSelectorApply()).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Fetches scope details on save', async () => {
|
|
||||||
await userEvents.click(getSelectorInput());
|
|
||||||
await userEvents.click(getResultClustersSelect());
|
|
||||||
await userEvents.click(getSelectorApply());
|
|
||||||
await waitFor(() => expect(fetchSelectedScopesSpy).toHaveBeenCalled());
|
|
||||||
expect(getClosestScopesFacade(dashboardScene)?.value).toEqual(
|
|
||||||
mocksScopes.filter(({ metadata: { name } }) => name === 'indexHelperCluster')
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Doesn't save the scopes on close", async () => {
|
|
||||||
await userEvents.click(getSelectorInput());
|
|
||||||
await userEvents.click(getResultClustersSelect());
|
|
||||||
await userEvents.click(getSelectorCancel());
|
|
||||||
await waitFor(() => expect(fetchSelectedScopesSpy).not.toHaveBeenCalled());
|
|
||||||
expect(getClosestScopesFacade(dashboardScene)?.value).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Shows selected scopes', async () => {
|
|
||||||
await userEvents.click(getSelectorInput());
|
|
||||||
await userEvents.click(getResultClustersSelect());
|
|
||||||
await userEvents.click(getSelectorApply());
|
|
||||||
expect(getSelectorInput().value).toEqual('Cluster Index Helper');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Dashboards list', () => {
|
|
||||||
it('Toggles expanded state', async () => {
|
|
||||||
await userEvents.click(getDashboardsExpand());
|
|
||||||
expect(getNotFoundNoScopes()).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Does not fetch dashboards list when the list is not expanded', async () => {
|
|
||||||
await userEvents.click(getSelectorInput());
|
|
||||||
await userEvents.click(getResultApplicationsExpand());
|
|
||||||
await userEvents.click(getResultApplicationsSlothPictureFactorySelect());
|
|
||||||
await userEvents.click(getSelectorApply());
|
|
||||||
await waitFor(() => expect(fetchSuggestedDashboardsSpy).not.toHaveBeenCalled());
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Fetches dashboards list when the list is expanded', async () => {
|
|
||||||
await userEvents.click(getDashboardsExpand());
|
|
||||||
await userEvents.click(getSelectorInput());
|
|
||||||
await userEvents.click(getResultApplicationsExpand());
|
|
||||||
await userEvents.click(getResultApplicationsSlothPictureFactorySelect());
|
|
||||||
await userEvents.click(getSelectorApply());
|
|
||||||
await waitFor(() => expect(fetchSuggestedDashboardsSpy).toHaveBeenCalled());
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Fetches dashboards list when the list is expanded after scope selection', async () => {
|
|
||||||
await userEvents.click(getSelectorInput());
|
|
||||||
await userEvents.click(getResultApplicationsExpand());
|
|
||||||
await userEvents.click(getResultApplicationsSlothPictureFactorySelect());
|
|
||||||
await userEvents.click(getSelectorApply());
|
|
||||||
await userEvents.click(getDashboardsExpand());
|
|
||||||
await waitFor(() => expect(fetchSuggestedDashboardsSpy).toHaveBeenCalled());
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Shows dashboards for multiple scopes', async () => {
|
|
||||||
await userEvents.click(getDashboardsExpand());
|
|
||||||
await userEvents.click(getSelectorInput());
|
|
||||||
await userEvents.click(getResultApplicationsExpand());
|
|
||||||
await userEvents.click(getResultApplicationsSlothPictureFactorySelect());
|
|
||||||
await userEvents.click(getSelectorApply());
|
|
||||||
expect(getDashboard('1')).toBeInTheDocument();
|
|
||||||
expect(getDashboard('2')).toBeInTheDocument();
|
|
||||||
expect(queryDashboard('3')).not.toBeInTheDocument();
|
|
||||||
expect(queryDashboard('4')).not.toBeInTheDocument();
|
|
||||||
await userEvents.click(getSelectorInput());
|
|
||||||
await userEvents.click(getResultApplicationsSlothVoteTrackerSelect());
|
|
||||||
await userEvents.click(getSelectorApply());
|
|
||||||
expect(getDashboard('1')).toBeInTheDocument();
|
|
||||||
expect(getDashboard('2')).toBeInTheDocument();
|
|
||||||
expect(getDashboard('3')).toBeInTheDocument();
|
|
||||||
expect(getDashboard('4')).toBeInTheDocument();
|
|
||||||
await userEvents.click(getSelectorInput());
|
|
||||||
await userEvents.click(getResultApplicationsSlothPictureFactorySelect());
|
|
||||||
await userEvents.click(getSelectorApply());
|
|
||||||
expect(queryDashboard('1')).not.toBeInTheDocument();
|
|
||||||
expect(queryDashboard('2')).not.toBeInTheDocument();
|
|
||||||
expect(getDashboard('3')).toBeInTheDocument();
|
|
||||||
expect(getDashboard('4')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Filters the dashboards list', async () => {
|
|
||||||
await userEvents.click(getDashboardsExpand());
|
|
||||||
await userEvents.click(getSelectorInput());
|
|
||||||
await userEvents.click(getResultApplicationsExpand());
|
|
||||||
await userEvents.click(getResultApplicationsSlothPictureFactorySelect());
|
|
||||||
await userEvents.click(getSelectorApply());
|
|
||||||
expect(getDashboard('1')).toBeInTheDocument();
|
|
||||||
expect(getDashboard('2')).toBeInTheDocument();
|
|
||||||
await userEvents.type(getDashboardsSearch(), '1');
|
|
||||||
expect(queryDashboard('2')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Deduplicates the dashboards list', async () => {
|
|
||||||
await userEvents.click(getDashboardsExpand());
|
|
||||||
await userEvents.click(getSelectorInput());
|
|
||||||
await userEvents.click(getResultApplicationsExpand());
|
|
||||||
await userEvents.click(getResultApplicationsClustersExpand());
|
|
||||||
await userEvents.click(getResultApplicationsClustersSlothClusterNorthSelect());
|
|
||||||
await userEvents.click(getResultApplicationsClustersSlothClusterSouthSelect());
|
|
||||||
await userEvents.click(getSelectorApply());
|
|
||||||
expect(queryAllDashboard('5')).toHaveLength(1);
|
|
||||||
expect(queryAllDashboard('6')).toHaveLength(1);
|
|
||||||
expect(queryAllDashboard('7')).toHaveLength(1);
|
|
||||||
expect(queryAllDashboard('8')).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Does show a proper message when no scopes are selected', async () => {
|
|
||||||
await userEvents.click(getDashboardsExpand());
|
|
||||||
expect(getNotFoundNoScopes()).toBeInTheDocument();
|
|
||||||
expect(queryDashboardsSearch()).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Does not show the input when there are no dashboards found for scope', async () => {
|
|
||||||
await userEvents.click(getDashboardsExpand());
|
|
||||||
await userEvents.click(getSelectorInput());
|
|
||||||
await userEvents.click(getResultClustersExpand());
|
|
||||||
await userEvents.click(getResultClustersSlothClusterEastRadio());
|
|
||||||
await userEvents.click(getSelectorApply());
|
|
||||||
expect(getNotFoundForScope()).toBeInTheDocument();
|
|
||||||
expect(queryDashboardsSearch()).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Does show the input and a message when there are no dashboards found for filter', async () => {
|
|
||||||
await userEvents.click(getDashboardsExpand());
|
|
||||||
await userEvents.click(getSelectorInput());
|
|
||||||
await userEvents.click(getResultApplicationsExpand());
|
|
||||||
await userEvents.click(getResultApplicationsSlothPictureFactorySelect());
|
|
||||||
await userEvents.click(getSelectorApply());
|
|
||||||
await userEvents.type(getDashboardsSearch(), 'unknown');
|
|
||||||
expect(queryDashboardsSearch()).toBeInTheDocument();
|
|
||||||
expect(getNotFoundForFilter()).toBeInTheDocument();
|
|
||||||
await userEvents.click(getNotFoundForFilterClear());
|
|
||||||
expect(getDashboardsSearch().value).toBe('');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('View mode', () => {
|
|
||||||
it('Enters view mode', async () => {
|
|
||||||
await act(async () => dashboardScene.onEnterEditMode());
|
|
||||||
expect(scopesSelectorScene?.state?.isReadOnly).toEqual(true);
|
|
||||||
expect(scopesDashboardsScene?.state?.isPanelOpened).toEqual(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Closes selector on enter', async () => {
|
|
||||||
await userEvents.click(getSelectorInput());
|
|
||||||
await act(async () => dashboardScene.onEnterEditMode());
|
|
||||||
expect(querySelectorApply()).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Closes dashboards list on enter', async () => {
|
|
||||||
await userEvents.click(getDashboardsExpand());
|
|
||||||
await act(async () => dashboardScene.onEnterEditMode());
|
|
||||||
expect(queryDashboardsContainer()).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Does not open selector when view mode is active', async () => {
|
|
||||||
await act(async () => dashboardScene.onEnterEditMode());
|
|
||||||
await userEvents.click(getSelectorInput());
|
|
||||||
expect(querySelectorApply()).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Disables the expand button when view mode is active', async () => {
|
|
||||||
await act(async () => dashboardScene.onEnterEditMode());
|
|
||||||
expect(getDashboardsExpand()).toBeDisabled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Enrichers', () => {
|
|
||||||
it('Data requests', async () => {
|
|
||||||
await userEvents.click(getSelectorInput());
|
|
||||||
await userEvents.click(getResultApplicationsExpand());
|
|
||||||
await userEvents.click(getResultApplicationsSlothPictureFactorySelect());
|
|
||||||
await userEvents.click(getSelectorApply());
|
|
||||||
await waitFor(() => {
|
|
||||||
const queryRunner = sceneGraph.findObject(dashboardScene, (o) => o.state.key === 'data-query-runner')!;
|
|
||||||
expect(dashboardScene.enrichDataRequest(queryRunner).scopes).toEqual(
|
|
||||||
mocksScopes.filter(({ metadata: { name } }) => name === 'slothPictureFactory')
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
await userEvents.click(getSelectorInput());
|
|
||||||
await userEvents.click(getResultApplicationsSlothVoteTrackerSelect());
|
|
||||||
await userEvents.click(getSelectorApply());
|
|
||||||
await waitFor(() => {
|
|
||||||
const queryRunner = sceneGraph.findObject(dashboardScene, (o) => o.state.key === 'data-query-runner')!;
|
|
||||||
expect(dashboardScene.enrichDataRequest(queryRunner).scopes).toEqual(
|
|
||||||
mocksScopes.filter(
|
|
||||||
({ metadata: { name } }) => name === 'slothPictureFactory' || name === 'slothVoteTracker'
|
|
||||||
)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
await userEvents.click(getSelectorInput());
|
|
||||||
await userEvents.click(getResultApplicationsSlothPictureFactorySelect());
|
|
||||||
await userEvents.click(getSelectorApply());
|
|
||||||
await waitFor(() => {
|
|
||||||
const queryRunner = sceneGraph.findObject(dashboardScene, (o) => o.state.key === 'data-query-runner')!;
|
|
||||||
expect(dashboardScene.enrichDataRequest(queryRunner).scopes).toEqual(
|
|
||||||
mocksScopes.filter(({ metadata: { name } }) => name === 'slothVoteTracker')
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Filters requests', async () => {
|
|
||||||
await userEvents.click(getSelectorInput());
|
|
||||||
await userEvents.click(getResultApplicationsExpand());
|
|
||||||
await userEvents.click(getResultApplicationsSlothPictureFactorySelect());
|
|
||||||
await userEvents.click(getSelectorApply());
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(dashboardScene.enrichFiltersRequest().scopes).toEqual(
|
|
||||||
mocksScopes.filter(({ metadata: { name } }) => name === 'slothPictureFactory')
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
await userEvents.click(getSelectorInput());
|
|
||||||
await userEvents.click(getResultApplicationsSlothVoteTrackerSelect());
|
|
||||||
await userEvents.click(getSelectorApply());
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(dashboardScene.enrichFiltersRequest().scopes).toEqual(
|
|
||||||
mocksScopes.filter(
|
|
||||||
({ metadata: { name } }) => name === 'slothPictureFactory' || name === 'slothVoteTracker'
|
|
||||||
)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
await userEvents.click(getSelectorInput());
|
|
||||||
await userEvents.click(getResultApplicationsSlothPictureFactorySelect());
|
|
||||||
await userEvents.click(getSelectorApply());
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(dashboardScene.enrichFiltersRequest().scopes).toEqual(
|
|
||||||
mocksScopes.filter(({ metadata: { name } }) => name === 'slothVoteTracker')
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Dashboards API', () => {
|
|
||||||
describe('Feature flag off', () => {
|
|
||||||
beforeAll(() => {
|
|
||||||
config.featureToggles.scopeFilters = true;
|
|
||||||
config.featureToggles.passScopeToDashboardApi = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
setDashboardAPI(undefined);
|
|
||||||
locationService.push('/?scopes=scope1&scopes=scope2&scopes=scope3');
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
resetScenes();
|
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Legacy API should not pass the scopes', async () => {
|
|
||||||
config.featureToggles.kubernetesDashboards = false;
|
|
||||||
getDashboardAPI().getDashboardDTO('1');
|
|
||||||
await waitFor(() => expect(getMock).toHaveBeenCalledWith('/api/dashboards/uid/1', undefined));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('K8s API should not pass the scopes', async () => {
|
|
||||||
config.featureToggles.kubernetesDashboards = true;
|
|
||||||
getDashboardAPI().getDashboardDTO('1');
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(getMock).toHaveBeenCalledWith(
|
|
||||||
'/apis/dashboard.grafana.app/v0alpha1/namespaces/default/dashboards/1/dto'
|
|
||||||
)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Feature flag on', () => {
|
|
||||||
beforeAll(() => {
|
|
||||||
config.featureToggles.scopeFilters = true;
|
|
||||||
config.featureToggles.passScopeToDashboardApi = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
setDashboardAPI(undefined);
|
|
||||||
locationService.push('/?scopes=scope1&scopes=scope2&scopes=scope3');
|
|
||||||
initializeScopes();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
resetScenes();
|
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Legacy API should pass the scopes', async () => {
|
|
||||||
config.featureToggles.kubernetesDashboards = false;
|
|
||||||
getDashboardAPI().getDashboardDTO('1');
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(getMock).toHaveBeenCalledWith('/api/dashboards/uid/1', { scopes: ['scope1', 'scope2', 'scope3'] })
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('K8s API should not pass the scopes', async () => {
|
|
||||||
config.featureToggles.kubernetesDashboards = true;
|
|
||||||
getDashboardAPI().getDashboardDTO('1');
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(getMock).toHaveBeenCalledWith(
|
|
||||||
'/apis/dashboard.grafana.app/v0alpha1/namespaces/default/dashboards/1/dto'
|
|
||||||
)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
@ -1,480 +0,0 @@
|
|||||||
import { screen } from '@testing-library/react';
|
|
||||||
import { KBarProvider } from 'kbar';
|
|
||||||
import { render } from 'test/test-utils';
|
|
||||||
|
|
||||||
import { Scope, ScopeDashboardBinding, ScopeNode } from '@grafana/data';
|
|
||||||
import {
|
|
||||||
AdHocFiltersVariable,
|
|
||||||
behaviors,
|
|
||||||
GroupByVariable,
|
|
||||||
sceneGraph,
|
|
||||||
SceneGridItem,
|
|
||||||
SceneGridLayout,
|
|
||||||
SceneQueryRunner,
|
|
||||||
SceneTimeRange,
|
|
||||||
SceneVariableSet,
|
|
||||||
VizPanel,
|
|
||||||
} from '@grafana/scenes';
|
|
||||||
import { AppChrome } from 'app/core/components/AppChrome/AppChrome';
|
|
||||||
import { DashboardControls } from 'app/features/dashboard-scene/scene//DashboardControls';
|
|
||||||
import { DashboardScene } from 'app/features/dashboard-scene/scene/DashboardScene';
|
|
||||||
|
|
||||||
import { ScopesFacade } from './ScopesFacadeScene';
|
|
||||||
import { scopesDashboardsScene, scopesSelectorScene } from './instance';
|
|
||||||
import { getInitialDashboardsState } from './internal/ScopesDashboardsScene';
|
|
||||||
import { initialSelectorState } from './internal/ScopesSelectorScene';
|
|
||||||
import * as api from './internal/api';
|
|
||||||
import { DASHBOARDS_OPENED_KEY } from './internal/const';
|
|
||||||
|
|
||||||
export const mocksScopes: Scope[] = [
|
|
||||||
{
|
|
||||||
metadata: { name: 'indexHelperCluster' },
|
|
||||||
spec: {
|
|
||||||
title: 'Cluster Index Helper',
|
|
||||||
type: 'indexHelper',
|
|
||||||
description: 'redundant label filter but makes queries faster',
|
|
||||||
category: 'indexHelpers',
|
|
||||||
filters: [{ key: 'indexHelper', value: 'cluster', operator: 'equals' }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
metadata: { name: 'slothClusterNorth' },
|
|
||||||
spec: {
|
|
||||||
title: 'slothClusterNorth',
|
|
||||||
type: 'cluster',
|
|
||||||
description: 'slothClusterNorth',
|
|
||||||
category: 'clusters',
|
|
||||||
filters: [{ key: 'cluster', value: 'slothClusterNorth', operator: 'equals' }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
metadata: { name: 'slothClusterSouth' },
|
|
||||||
spec: {
|
|
||||||
title: 'slothClusterSouth',
|
|
||||||
type: 'cluster',
|
|
||||||
description: 'slothClusterSouth',
|
|
||||||
category: 'clusters',
|
|
||||||
filters: [{ key: 'cluster', value: 'slothClusterSouth', operator: 'equals' }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
metadata: { name: 'slothClusterEast' },
|
|
||||||
spec: {
|
|
||||||
title: 'slothClusterEast',
|
|
||||||
type: 'cluster',
|
|
||||||
description: 'slothClusterEast',
|
|
||||||
category: 'clusters',
|
|
||||||
filters: [{ key: 'cluster', value: 'slothClusterEast', operator: 'equals' }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
metadata: { name: 'slothPictureFactory' },
|
|
||||||
spec: {
|
|
||||||
title: 'slothPictureFactory',
|
|
||||||
type: 'app',
|
|
||||||
description: 'slothPictureFactory',
|
|
||||||
category: 'apps',
|
|
||||||
filters: [{ key: 'app', value: 'slothPictureFactory', operator: 'equals' }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
metadata: { name: 'slothVoteTracker' },
|
|
||||||
spec: {
|
|
||||||
title: 'slothVoteTracker',
|
|
||||||
type: 'app',
|
|
||||||
description: 'slothVoteTracker',
|
|
||||||
category: 'apps',
|
|
||||||
filters: [{ key: 'app', value: 'slothVoteTracker', operator: 'equals' }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export const mocksScopeDashboardBindings: ScopeDashboardBinding[] = [
|
|
||||||
{
|
|
||||||
metadata: { name: 'binding1' },
|
|
||||||
spec: { dashboard: '1', dashboardTitle: 'My Dashboard 1', scope: 'slothPictureFactory' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
metadata: { name: 'binding2' },
|
|
||||||
spec: { dashboard: '2', dashboardTitle: 'My Dashboard 2', scope: 'slothPictureFactory' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
metadata: { name: 'binding3' },
|
|
||||||
spec: { dashboard: '3', dashboardTitle: 'My Dashboard 3', scope: 'slothVoteTracker' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
metadata: { name: 'binding4' },
|
|
||||||
spec: { dashboard: '4', dashboardTitle: 'My Dashboard 4', scope: 'slothVoteTracker' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
metadata: { name: 'binding5' },
|
|
||||||
spec: { dashboard: '5', dashboardTitle: 'My Dashboard 5', scope: 'slothClusterNorth' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
metadata: { name: 'binding6' },
|
|
||||||
spec: { dashboard: '6', dashboardTitle: 'My Dashboard 6', scope: 'slothClusterNorth' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
metadata: { name: 'binding7' },
|
|
||||||
spec: { dashboard: '7', dashboardTitle: 'My Dashboard 7', scope: 'slothClusterNorth' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
metadata: { name: 'binding8' },
|
|
||||||
spec: { dashboard: '5', dashboardTitle: 'My Dashboard 5', scope: 'slothClusterSouth' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
metadata: { name: 'binding9' },
|
|
||||||
spec: { dashboard: '6', dashboardTitle: 'My Dashboard 6', scope: 'slothClusterSouth' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
metadata: { name: 'binding10' },
|
|
||||||
spec: { dashboard: '8', dashboardTitle: 'My Dashboard 8', scope: 'slothClusterSouth' },
|
|
||||||
},
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export const mocksNodes: Array<ScopeNode & { parent: string }> = [
|
|
||||||
{
|
|
||||||
parent: '',
|
|
||||||
metadata: { name: 'applications' },
|
|
||||||
spec: {
|
|
||||||
nodeType: 'container',
|
|
||||||
title: 'Applications',
|
|
||||||
description: 'Application Scopes',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
parent: '',
|
|
||||||
metadata: { name: 'clusters' },
|
|
||||||
spec: {
|
|
||||||
nodeType: 'container',
|
|
||||||
title: 'Clusters',
|
|
||||||
description: 'Cluster Scopes',
|
|
||||||
disableMultiSelect: true,
|
|
||||||
linkType: 'scope',
|
|
||||||
linkId: 'indexHelperCluster',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
parent: 'applications',
|
|
||||||
metadata: { name: 'applications-slothPictureFactory' },
|
|
||||||
spec: {
|
|
||||||
nodeType: 'leaf',
|
|
||||||
title: 'slothPictureFactory',
|
|
||||||
description: 'slothPictureFactory',
|
|
||||||
linkType: 'scope',
|
|
||||||
linkId: 'slothPictureFactory',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
parent: 'applications',
|
|
||||||
metadata: { name: 'applications-slothVoteTracker' },
|
|
||||||
spec: {
|
|
||||||
nodeType: 'leaf',
|
|
||||||
title: 'slothVoteTracker',
|
|
||||||
description: 'slothVoteTracker',
|
|
||||||
linkType: 'scope',
|
|
||||||
linkId: 'slothVoteTracker',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
parent: 'applications',
|
|
||||||
metadata: { name: 'applications-clusters' },
|
|
||||||
spec: {
|
|
||||||
nodeType: 'container',
|
|
||||||
title: 'Clusters',
|
|
||||||
description: 'Application/Clusters Scopes',
|
|
||||||
linkType: 'scope',
|
|
||||||
linkId: 'indexHelperCluster',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
parent: 'applications-clusters',
|
|
||||||
metadata: { name: 'applications-clusters-slothClusterNorth' },
|
|
||||||
spec: {
|
|
||||||
nodeType: 'leaf',
|
|
||||||
title: 'slothClusterNorth',
|
|
||||||
description: 'slothClusterNorth',
|
|
||||||
linkType: 'scope',
|
|
||||||
linkId: 'slothClusterNorth',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
parent: 'applications-clusters',
|
|
||||||
metadata: { name: 'applications-clusters-slothClusterSouth' },
|
|
||||||
spec: {
|
|
||||||
nodeType: 'leaf',
|
|
||||||
title: 'slothClusterSouth',
|
|
||||||
description: 'slothClusterSouth',
|
|
||||||
linkType: 'scope',
|
|
||||||
linkId: 'slothClusterSouth',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
parent: 'clusters',
|
|
||||||
metadata: { name: 'clusters-slothClusterNorth' },
|
|
||||||
spec: {
|
|
||||||
nodeType: 'leaf',
|
|
||||||
title: 'slothClusterNorth',
|
|
||||||
description: 'slothClusterNorth',
|
|
||||||
linkType: 'scope',
|
|
||||||
linkId: 'slothClusterNorth',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
parent: 'clusters',
|
|
||||||
metadata: { name: 'clusters-slothClusterSouth' },
|
|
||||||
spec: {
|
|
||||||
nodeType: 'leaf',
|
|
||||||
title: 'slothClusterSouth',
|
|
||||||
description: 'slothClusterSouth',
|
|
||||||
linkType: 'scope',
|
|
||||||
linkId: 'slothClusterSouth',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
parent: 'clusters',
|
|
||||||
metadata: { name: 'clusters-slothClusterEast' },
|
|
||||||
spec: {
|
|
||||||
nodeType: 'leaf',
|
|
||||||
title: 'slothClusterEast',
|
|
||||||
description: 'slothClusterEast',
|
|
||||||
linkType: 'scope',
|
|
||||||
linkId: 'slothClusterEast',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
parent: 'clusters',
|
|
||||||
metadata: { name: 'clusters-applications' },
|
|
||||||
spec: {
|
|
||||||
nodeType: 'container',
|
|
||||||
title: 'Applications',
|
|
||||||
description: 'Clusters/Application Scopes',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
parent: 'clusters-applications',
|
|
||||||
metadata: { name: 'clusters-applications-slothPictureFactory' },
|
|
||||||
spec: {
|
|
||||||
nodeType: 'leaf',
|
|
||||||
title: 'slothPictureFactory',
|
|
||||||
description: 'slothPictureFactory',
|
|
||||||
linkType: 'scope',
|
|
||||||
linkId: 'slothPictureFactory',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
parent: 'clusters-applications',
|
|
||||||
metadata: { name: 'clusters-applications-slothVoteTracker' },
|
|
||||||
spec: {
|
|
||||||
nodeType: 'leaf',
|
|
||||||
title: 'slothVoteTracker',
|
|
||||||
description: 'slothVoteTracker',
|
|
||||||
linkType: 'scope',
|
|
||||||
linkId: 'slothVoteTracker',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export const fetchNodesSpy = jest.spyOn(api, 'fetchNodes');
|
|
||||||
export const fetchScopeSpy = jest.spyOn(api, 'fetchScope');
|
|
||||||
export const fetchSelectedScopesSpy = jest.spyOn(api, 'fetchSelectedScopes');
|
|
||||||
export const fetchSuggestedDashboardsSpy = jest.spyOn(api, 'fetchSuggestedDashboards');
|
|
||||||
|
|
||||||
export const getMock = jest
|
|
||||||
.fn()
|
|
||||||
.mockImplementation((url: string, params: { parent: string; scope: string[]; query?: string }) => {
|
|
||||||
if (url.startsWith('/apis/scope.grafana.app/v0alpha1/namespaces/default/find/scope_node_children')) {
|
|
||||||
return {
|
|
||||||
items: mocksNodes.filter(
|
|
||||||
({ parent, spec: { title } }) => parent === params.parent && title.includes(params.query ?? '')
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url.startsWith('/apis/scope.grafana.app/v0alpha1/namespaces/default/scopes/')) {
|
|
||||||
const name = url.replace('/apis/scope.grafana.app/v0alpha1/namespaces/default/scopes/', '');
|
|
||||||
|
|
||||||
return mocksScopes.find((scope) => scope.metadata.name === name) ?? {};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url.startsWith('/apis/scope.grafana.app/v0alpha1/namespaces/default/find/scope_dashboard_bindings')) {
|
|
||||||
return {
|
|
||||||
items: mocksScopeDashboardBindings.filter(({ spec: { scope: bindingScope } }) =>
|
|
||||||
params.scope.includes(bindingScope)
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url.startsWith('/api/dashboards/uid/')) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url.startsWith('/apis/dashboard.grafana.app/v0alpha1/namespaces/default/dashboards/')) {
|
|
||||||
return {
|
|
||||||
metadata: {
|
|
||||||
name: '1',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {};
|
|
||||||
});
|
|
||||||
|
|
||||||
const selectors = {
|
|
||||||
tree: {
|
|
||||||
search: 'scopes-tree-search',
|
|
||||||
headline: 'scopes-tree-headline',
|
|
||||||
select: (nodeId: string, type: 'result' | 'persisted') => `scopes-tree-${type}-${nodeId}-checkbox`,
|
|
||||||
radio: (nodeId: string, type: 'result' | 'persisted') => `scopes-tree-${type}-${nodeId}-radio`,
|
|
||||||
expand: (nodeId: string, type: 'result' | 'persisted') => `scopes-tree-${type}-${nodeId}-expand`,
|
|
||||||
title: (nodeId: string, type: 'result' | 'persisted') => `scopes-tree-${type}-${nodeId}-title`,
|
|
||||||
},
|
|
||||||
selector: {
|
|
||||||
input: 'scopes-selector-input',
|
|
||||||
container: 'scopes-selector-container',
|
|
||||||
loading: 'scopes-selector-loading',
|
|
||||||
apply: 'scopes-selector-apply',
|
|
||||||
cancel: 'scopes-selector-cancel',
|
|
||||||
},
|
|
||||||
dashboards: {
|
|
||||||
expand: 'scopes-dashboards-expand',
|
|
||||||
container: 'scopes-dashboards-container',
|
|
||||||
search: 'scopes-dashboards-search',
|
|
||||||
loading: 'scopes-dashboards-loading',
|
|
||||||
dashboard: (uid: string) => `scopes-dashboards-${uid}`,
|
|
||||||
notFoundNoScopes: 'scopes-dashboards-notFoundNoScopes',
|
|
||||||
notFoundForScope: 'scopes-dashboards-notFoundForScope',
|
|
||||||
notFoundForFilter: 'scopes-dashboards-notFoundForFilter',
|
|
||||||
notFoundForFilterClear: 'scopes-dashboards-notFoundForFilter-clear',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getSelectorInput = () => screen.getByTestId<HTMLInputElement>(selectors.selector.input);
|
|
||||||
export const querySelectorApply = () => screen.queryByTestId(selectors.selector.apply);
|
|
||||||
export const getSelectorApply = () => screen.getByTestId(selectors.selector.apply);
|
|
||||||
export const getSelectorCancel = () => screen.getByTestId(selectors.selector.cancel);
|
|
||||||
|
|
||||||
export const getDashboardsExpand = () => screen.getByTestId(selectors.dashboards.expand);
|
|
||||||
export const queryDashboardsContainer = () => screen.queryByTestId(selectors.dashboards.container);
|
|
||||||
export const queryDashboardsSearch = () => screen.queryByTestId(selectors.dashboards.search);
|
|
||||||
export const getDashboardsSearch = () => screen.getByTestId<HTMLInputElement>(selectors.dashboards.search);
|
|
||||||
export const queryAllDashboard = (uid: string) => screen.queryAllByTestId(selectors.dashboards.dashboard(uid));
|
|
||||||
export const queryDashboard = (uid: string) => screen.queryByTestId(selectors.dashboards.dashboard(uid));
|
|
||||||
export const getDashboard = (uid: string) => screen.getByTestId(selectors.dashboards.dashboard(uid));
|
|
||||||
export const getNotFoundNoScopes = () => screen.getByTestId(selectors.dashboards.notFoundNoScopes);
|
|
||||||
export const getNotFoundForScope = () => screen.getByTestId(selectors.dashboards.notFoundForScope);
|
|
||||||
export const getNotFoundForFilter = () => screen.getByTestId(selectors.dashboards.notFoundForFilter);
|
|
||||||
export const getNotFoundForFilterClear = () => screen.getByTestId(selectors.dashboards.notFoundForFilterClear);
|
|
||||||
|
|
||||||
export const getTreeSearch = () => screen.getByTestId<HTMLInputElement>(selectors.tree.search);
|
|
||||||
export const getTreeHeadline = () => screen.getByTestId(selectors.tree.headline);
|
|
||||||
export const getResultApplicationsExpand = () => screen.getByTestId(selectors.tree.expand('applications', 'result'));
|
|
||||||
export const queryResultApplicationsSlothPictureFactoryTitle = () =>
|
|
||||||
screen.queryByTestId(selectors.tree.title('applications-slothPictureFactory', 'result'));
|
|
||||||
export const getResultApplicationsSlothPictureFactoryTitle = () =>
|
|
||||||
screen.getByTestId(selectors.tree.title('applications-slothPictureFactory', 'result'));
|
|
||||||
export const getResultApplicationsSlothPictureFactorySelect = () =>
|
|
||||||
screen.getByTestId(selectors.tree.select('applications-slothPictureFactory', 'result'));
|
|
||||||
export const queryPersistedApplicationsSlothPictureFactoryTitle = () =>
|
|
||||||
screen.queryByTestId(selectors.tree.title('applications-slothPictureFactory', 'persisted'));
|
|
||||||
export const getPersistedApplicationsSlothPictureFactoryTitle = () =>
|
|
||||||
screen.getByTestId(selectors.tree.title('applications-slothPictureFactory', 'persisted'));
|
|
||||||
export const getPersistedApplicationsSlothPictureFactorySelect = () =>
|
|
||||||
screen.getByTestId(selectors.tree.select('applications-slothPictureFactory', 'persisted'));
|
|
||||||
export const queryResultApplicationsSlothVoteTrackerTitle = () =>
|
|
||||||
screen.queryByTestId(selectors.tree.title('applications-slothVoteTracker', 'result'));
|
|
||||||
export const getResultApplicationsSlothVoteTrackerTitle = () =>
|
|
||||||
screen.getByTestId(selectors.tree.title('applications-slothVoteTracker', 'result'));
|
|
||||||
export const getResultApplicationsSlothVoteTrackerSelect = () =>
|
|
||||||
screen.getByTestId(selectors.tree.select('applications-slothVoteTracker', 'result'));
|
|
||||||
export const queryPersistedApplicationsSlothVoteTrackerTitle = () =>
|
|
||||||
screen.queryByTestId(selectors.tree.title('applications-slothVoteTracker', 'persisted'));
|
|
||||||
export const getPersistedApplicationsSlothVoteTrackerTitle = () =>
|
|
||||||
screen.getByTestId(selectors.tree.title('applications-slothVoteTracker', 'persisted'));
|
|
||||||
export const queryResultApplicationsClustersTitle = () =>
|
|
||||||
screen.queryByTestId(selectors.tree.title('applications-clusters', 'result'));
|
|
||||||
export const getResultApplicationsClustersSelect = () =>
|
|
||||||
screen.getByTestId(selectors.tree.select('applications-clusters', 'result'));
|
|
||||||
export const getResultApplicationsClustersExpand = () =>
|
|
||||||
screen.getByTestId(selectors.tree.expand('applications-clusters', 'result'));
|
|
||||||
export const getResultApplicationsClustersSlothClusterNorthSelect = () =>
|
|
||||||
screen.getByTestId(selectors.tree.select('applications-clusters-slothClusterNorth', 'result'));
|
|
||||||
export const getResultApplicationsClustersSlothClusterSouthSelect = () =>
|
|
||||||
screen.getByTestId(selectors.tree.select('applications-clusters-slothClusterSouth', 'result'));
|
|
||||||
|
|
||||||
export const getResultClustersSelect = () => screen.getByTestId(selectors.tree.select('clusters', 'result'));
|
|
||||||
export const getResultClustersExpand = () => screen.getByTestId(selectors.tree.expand('clusters', 'result'));
|
|
||||||
export const getResultClustersSlothClusterNorthRadio = () =>
|
|
||||||
screen.getByTestId<HTMLInputElement>(selectors.tree.radio('clusters-slothClusterNorth', 'result'));
|
|
||||||
export const getResultClustersSlothClusterSouthRadio = () =>
|
|
||||||
screen.getByTestId<HTMLInputElement>(selectors.tree.radio('clusters-slothClusterSouth', 'result'));
|
|
||||||
export const getResultClustersSlothClusterEastRadio = () =>
|
|
||||||
screen.getByTestId<HTMLInputElement>(selectors.tree.radio('clusters-slothClusterEast', 'result'));
|
|
||||||
|
|
||||||
export function buildTestScene(overrides: Partial<DashboardScene> = {}) {
|
|
||||||
return new DashboardScene({
|
|
||||||
title: 'hello',
|
|
||||||
uid: 'dash-1',
|
|
||||||
description: 'hello description',
|
|
||||||
tags: ['tag1', 'tag2'],
|
|
||||||
editable: true,
|
|
||||||
$timeRange: new SceneTimeRange({
|
|
||||||
timeZone: 'browser',
|
|
||||||
}),
|
|
||||||
controls: new DashboardControls({}),
|
|
||||||
$behaviors: [
|
|
||||||
new behaviors.CursorSync({}),
|
|
||||||
new ScopesFacade({
|
|
||||||
handler: (facade) => sceneGraph.getTimeRange(facade).onRefresh(),
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
$variables: new SceneVariableSet({
|
|
||||||
variables: [
|
|
||||||
new AdHocFiltersVariable({
|
|
||||||
name: 'adhoc',
|
|
||||||
datasource: { uid: 'my-ds-uid' },
|
|
||||||
}),
|
|
||||||
new GroupByVariable({
|
|
||||||
name: 'groupby',
|
|
||||||
datasource: { uid: 'my-ds-uid' },
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
body: new SceneGridLayout({
|
|
||||||
children: [
|
|
||||||
new SceneGridItem({
|
|
||||||
key: 'griditem-1',
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
width: 300,
|
|
||||||
height: 300,
|
|
||||||
body: new VizPanel({
|
|
||||||
title: 'Panel A',
|
|
||||||
key: 'panel-1',
|
|
||||||
pluginId: 'table',
|
|
||||||
$data: new SceneQueryRunner({ key: 'data-query-runner', queries: [{ refId: 'A' }] }),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
...overrides,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function renderDashboard(dashboardScene: DashboardScene) {
|
|
||||||
return render(
|
|
||||||
<KBarProvider>
|
|
||||||
<AppChrome>
|
|
||||||
<dashboardScene.Component model={dashboardScene} />
|
|
||||||
</AppChrome>
|
|
||||||
</KBarProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resetScenes() {
|
|
||||||
scopesSelectorScene?.setState(initialSelectorState);
|
|
||||||
|
|
||||||
localStorage.removeItem(DASHBOARDS_OPENED_KEY);
|
|
||||||
|
|
||||||
scopesDashboardsScene?.setState(getInitialDashboardsState());
|
|
||||||
}
|
|
84
public/app/features/scopes/tests/dashboardsApi.test.ts
Normal file
84
public/app/features/scopes/tests/dashboardsApi.test.ts
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
import { cleanup } from '@testing-library/react';
|
||||||
|
|
||||||
|
import { config, locationService } from '@grafana/runtime';
|
||||||
|
import { getDashboardAPI, setDashboardAPI } from 'app/features/dashboard/api/dashboard_api';
|
||||||
|
|
||||||
|
import { initializeScopes } from '../instance';
|
||||||
|
|
||||||
|
import { getMock } from './utils/mocks';
|
||||||
|
import { resetScenes } from './utils/render';
|
||||||
|
|
||||||
|
jest.mock('@grafana/runtime', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
...jest.requireActual('@grafana/runtime'),
|
||||||
|
getBackendSrv: () => ({
|
||||||
|
get: getMock,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('Scopes', () => {
|
||||||
|
describe('Dashboards API', () => {
|
||||||
|
describe('Feature flag off', () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
config.featureToggles.scopeFilters = true;
|
||||||
|
config.featureToggles.passScopeToDashboardApi = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setDashboardAPI(undefined);
|
||||||
|
locationService.push('/?scopes=scope1&scopes=scope2&scopes=scope3');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
resetScenes();
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Legacy API should not pass the scopes', async () => {
|
||||||
|
config.featureToggles.kubernetesDashboards = false;
|
||||||
|
await getDashboardAPI().getDashboardDTO('1');
|
||||||
|
expect(getMock).toHaveBeenCalledWith('/api/dashboards/uid/1', undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('K8s API should not pass the scopes', async () => {
|
||||||
|
config.featureToggles.kubernetesDashboards = true;
|
||||||
|
await getDashboardAPI().getDashboardDTO('1');
|
||||||
|
expect(getMock).toHaveBeenCalledWith(
|
||||||
|
'/apis/dashboard.grafana.app/v0alpha1/namespaces/default/dashboards/1/dto'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Feature flag on', () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
config.featureToggles.scopeFilters = true;
|
||||||
|
config.featureToggles.passScopeToDashboardApi = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setDashboardAPI(undefined);
|
||||||
|
locationService.push('/?scopes=scope1&scopes=scope2&scopes=scope3');
|
||||||
|
initializeScopes();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
resetScenes();
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Legacy API should pass the scopes', async () => {
|
||||||
|
config.featureToggles.kubernetesDashboards = false;
|
||||||
|
await getDashboardAPI().getDashboardDTO('1');
|
||||||
|
expect(getMock).toHaveBeenCalledWith('/api/dashboards/uid/1', { scopes: ['scope1', 'scope2', 'scope3'] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('K8s API should not pass the scopes', async () => {
|
||||||
|
config.featureToggles.kubernetesDashboards = true;
|
||||||
|
await getDashboardAPI().getDashboardDTO('1');
|
||||||
|
expect(getMock).toHaveBeenCalledWith(
|
||||||
|
'/apis/dashboard.grafana.app/v0alpha1/namespaces/default/dashboards/1/dto'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
687
public/app/features/scopes/tests/scopes.test.ts
Normal file
687
public/app/features/scopes/tests/scopes.test.ts
Normal file
@ -0,0 +1,687 @@
|
|||||||
|
import { act, cleanup, waitFor } from '@testing-library/react';
|
||||||
|
import userEvents from '@testing-library/user-event';
|
||||||
|
|
||||||
|
import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
|
||||||
|
import { config, setPluginImportUtils } from '@grafana/runtime';
|
||||||
|
import { sceneGraph } from '@grafana/scenes';
|
||||||
|
import { DashboardScene } from 'app/features/dashboard-scene/scene/DashboardScene';
|
||||||
|
|
||||||
|
import { initializeScopes, scopesDashboardsScene, scopesSelectorScene } from '../instance';
|
||||||
|
import { getClosestScopesFacade } from '../utils';
|
||||||
|
|
||||||
|
import {
|
||||||
|
fetchDashboardsSpy,
|
||||||
|
fetchNodesSpy,
|
||||||
|
fetchScopeSpy,
|
||||||
|
fetchSelectedScopesSpy,
|
||||||
|
getMock,
|
||||||
|
mocksScopes,
|
||||||
|
} from './utils/mocks';
|
||||||
|
import { buildTestScene, renderDashboard, resetScenes } from './utils/render';
|
||||||
|
import {
|
||||||
|
getDashboard,
|
||||||
|
getDashboardFolderExpand,
|
||||||
|
getDashboardsExpand,
|
||||||
|
getDashboardsSearch,
|
||||||
|
getNotFoundForFilter,
|
||||||
|
getNotFoundForFilterClear,
|
||||||
|
getNotFoundForScope,
|
||||||
|
getNotFoundNoScopes,
|
||||||
|
getPersistedApplicationsMimirSelect,
|
||||||
|
getPersistedApplicationsMimirTitle,
|
||||||
|
getResultApplicationsCloudDevSelect,
|
||||||
|
getResultApplicationsCloudExpand,
|
||||||
|
getResultApplicationsCloudOpsSelect,
|
||||||
|
getResultApplicationsCloudSelect,
|
||||||
|
getResultApplicationsExpand,
|
||||||
|
getResultApplicationsGrafanaSelect,
|
||||||
|
getResultApplicationsGrafanaTitle,
|
||||||
|
getResultApplicationsMimirSelect,
|
||||||
|
getResultApplicationsMimirTitle,
|
||||||
|
getResultCloudDevRadio,
|
||||||
|
getResultCloudExpand,
|
||||||
|
getResultCloudOpsRadio,
|
||||||
|
getResultCloudSelect,
|
||||||
|
getSelectorApply,
|
||||||
|
getSelectorCancel,
|
||||||
|
getSelectorInput,
|
||||||
|
getTreeHeadline,
|
||||||
|
getTreeSearch,
|
||||||
|
queryAllDashboard,
|
||||||
|
queryDashboard,
|
||||||
|
queryDashboardFolderExpand,
|
||||||
|
queryDashboardsContainer,
|
||||||
|
queryDashboardsSearch,
|
||||||
|
queryPersistedApplicationsGrafanaTitle,
|
||||||
|
queryPersistedApplicationsMimirTitle,
|
||||||
|
queryResultApplicationsCloudTitle,
|
||||||
|
queryResultApplicationsGrafanaTitle,
|
||||||
|
queryResultApplicationsMimirTitle,
|
||||||
|
querySelectorApply,
|
||||||
|
} from './utils/selectors';
|
||||||
|
|
||||||
|
jest.mock('@grafana/runtime', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
...jest.requireActual('@grafana/runtime'),
|
||||||
|
useChromeHeaderHeight: jest.fn(),
|
||||||
|
getBackendSrv: () => ({
|
||||||
|
get: getMock,
|
||||||
|
}),
|
||||||
|
usePluginLinkExtensions: jest.fn().mockReturnValue({ extensions: [] }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const panelPlugin = getPanelPlugin({
|
||||||
|
id: 'table',
|
||||||
|
skipDataQuery: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
config.panels['table'] = panelPlugin.meta;
|
||||||
|
|
||||||
|
setPluginImportUtils({
|
||||||
|
importPanelPlugin: () => Promise.resolve(panelPlugin),
|
||||||
|
getPanelPluginFromCache: () => undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Scopes', () => {
|
||||||
|
describe('Feature flag off', () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
config.featureToggles.scopeFilters = false;
|
||||||
|
config.featureToggles.groupByVariable = true;
|
||||||
|
|
||||||
|
initializeScopes();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Does not initialize', () => {
|
||||||
|
const dashboardScene = buildTestScene();
|
||||||
|
dashboardScene.activate();
|
||||||
|
expect(scopesSelectorScene).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Feature flag on', () => {
|
||||||
|
let dashboardScene: DashboardScene;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
config.featureToggles.scopeFilters = true;
|
||||||
|
config.featureToggles.groupByVariable = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.spyOn(console, 'error').mockImplementation(jest.fn());
|
||||||
|
|
||||||
|
fetchNodesSpy.mockClear();
|
||||||
|
fetchScopeSpy.mockClear();
|
||||||
|
fetchSelectedScopesSpy.mockClear();
|
||||||
|
fetchDashboardsSpy.mockClear();
|
||||||
|
getMock.mockClear();
|
||||||
|
|
||||||
|
initializeScopes();
|
||||||
|
|
||||||
|
dashboardScene = buildTestScene();
|
||||||
|
|
||||||
|
renderDashboard(dashboardScene);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
resetScenes();
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Tree', () => {
|
||||||
|
it('Navigates through scopes nodes', async () => {
|
||||||
|
await userEvents.click(getSelectorInput());
|
||||||
|
await userEvents.click(getResultApplicationsExpand());
|
||||||
|
await userEvents.click(getResultApplicationsCloudExpand());
|
||||||
|
await userEvents.click(getResultApplicationsExpand());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Fetches scope details on select', async () => {
|
||||||
|
await userEvents.click(getSelectorInput());
|
||||||
|
await userEvents.click(getResultApplicationsExpand());
|
||||||
|
await userEvents.click(getResultApplicationsGrafanaSelect());
|
||||||
|
await waitFor(() => expect(fetchScopeSpy).toHaveBeenCalledTimes(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Selects the proper scopes', async () => {
|
||||||
|
await act(async () =>
|
||||||
|
scopesSelectorScene?.updateScopes([
|
||||||
|
{ scopeName: 'grafana', path: [] },
|
||||||
|
{ scopeName: 'mimir', path: [] },
|
||||||
|
])
|
||||||
|
);
|
||||||
|
await userEvents.click(getSelectorInput());
|
||||||
|
await userEvents.click(getResultApplicationsExpand());
|
||||||
|
expect(getResultApplicationsGrafanaSelect()).toBeChecked();
|
||||||
|
expect(getResultApplicationsMimirSelect()).toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Can select scopes from same level', async () => {
|
||||||
|
await userEvents.click(getSelectorInput());
|
||||||
|
await userEvents.click(getResultApplicationsExpand());
|
||||||
|
await userEvents.click(getResultApplicationsGrafanaSelect());
|
||||||
|
await userEvents.click(getResultApplicationsMimirSelect());
|
||||||
|
await userEvents.click(getResultApplicationsCloudSelect());
|
||||||
|
await userEvents.click(getSelectorApply());
|
||||||
|
expect(getSelectorInput().value).toBe('Grafana, Mimir, Cloud');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Can select a node from an inner level', async () => {
|
||||||
|
await userEvents.click(getSelectorInput());
|
||||||
|
await userEvents.click(getResultApplicationsExpand());
|
||||||
|
await userEvents.click(getResultApplicationsGrafanaSelect());
|
||||||
|
await userEvents.click(getResultApplicationsCloudExpand());
|
||||||
|
await userEvents.click(getResultApplicationsCloudDevSelect());
|
||||||
|
await userEvents.click(getSelectorApply());
|
||||||
|
expect(getSelectorInput().value).toBe('Dev');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Can select a node from an upper level', async () => {
|
||||||
|
await userEvents.click(getSelectorInput());
|
||||||
|
await userEvents.click(getResultApplicationsExpand());
|
||||||
|
await userEvents.click(getResultApplicationsGrafanaSelect());
|
||||||
|
await userEvents.click(getResultApplicationsExpand());
|
||||||
|
await userEvents.click(getResultCloudSelect());
|
||||||
|
await userEvents.click(getSelectorApply());
|
||||||
|
expect(getSelectorInput().value).toBe('Cloud');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Respects only one select per container', async () => {
|
||||||
|
await userEvents.click(getSelectorInput());
|
||||||
|
await userEvents.click(getResultCloudExpand());
|
||||||
|
await userEvents.click(getResultCloudDevRadio());
|
||||||
|
expect(getResultCloudDevRadio().checked).toBe(true);
|
||||||
|
expect(getResultCloudOpsRadio().checked).toBe(false);
|
||||||
|
await userEvents.click(getResultCloudOpsRadio());
|
||||||
|
expect(getResultCloudDevRadio().checked).toBe(false);
|
||||||
|
expect(getResultCloudOpsRadio().checked).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Search works', async () => {
|
||||||
|
await userEvents.click(getSelectorInput());
|
||||||
|
await userEvents.click(getResultApplicationsExpand());
|
||||||
|
await userEvents.type(getTreeSearch(), 'Cloud');
|
||||||
|
await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(3));
|
||||||
|
expect(queryResultApplicationsGrafanaTitle()).not.toBeInTheDocument();
|
||||||
|
expect(queryResultApplicationsMimirTitle()).not.toBeInTheDocument();
|
||||||
|
expect(getResultApplicationsCloudSelect()).toBeInTheDocument();
|
||||||
|
await userEvents.clear(getTreeSearch());
|
||||||
|
await userEvents.type(getTreeSearch(), 'Grafana');
|
||||||
|
await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(4));
|
||||||
|
expect(getResultApplicationsGrafanaSelect()).toBeInTheDocument();
|
||||||
|
expect(queryResultApplicationsCloudTitle()).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Opens to a selected scope', async () => {
|
||||||
|
await userEvents.click(getSelectorInput());
|
||||||
|
await userEvents.click(getResultApplicationsExpand());
|
||||||
|
await userEvents.click(getResultApplicationsMimirSelect());
|
||||||
|
await userEvents.click(getResultApplicationsExpand());
|
||||||
|
await userEvents.click(getResultCloudExpand());
|
||||||
|
await userEvents.click(getSelectorApply());
|
||||||
|
await userEvents.click(getSelectorInput());
|
||||||
|
expect(queryResultApplicationsMimirTitle()).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Persists a scope', async () => {
|
||||||
|
await userEvents.click(getSelectorInput());
|
||||||
|
await userEvents.click(getResultApplicationsExpand());
|
||||||
|
await userEvents.click(getResultApplicationsMimirSelect());
|
||||||
|
await userEvents.type(getTreeSearch(), 'grafana');
|
||||||
|
await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(3));
|
||||||
|
expect(getPersistedApplicationsMimirTitle()).toBeInTheDocument();
|
||||||
|
expect(queryPersistedApplicationsGrafanaTitle()).not.toBeInTheDocument();
|
||||||
|
expect(queryResultApplicationsMimirTitle()).not.toBeInTheDocument();
|
||||||
|
expect(getResultApplicationsGrafanaTitle()).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Does not persist a retrieved scope', async () => {
|
||||||
|
await userEvents.click(getSelectorInput());
|
||||||
|
await userEvents.click(getResultApplicationsExpand());
|
||||||
|
await userEvents.click(getResultApplicationsMimirSelect());
|
||||||
|
await userEvents.type(getTreeSearch(), 'mimir');
|
||||||
|
await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(3));
|
||||||
|
expect(queryPersistedApplicationsMimirTitle()).not.toBeInTheDocument();
|
||||||
|
expect(getResultApplicationsMimirTitle()).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Removes persisted nodes', async () => {
|
||||||
|
await userEvents.click(getSelectorInput());
|
||||||
|
await userEvents.click(getResultApplicationsExpand());
|
||||||
|
await userEvents.click(getResultApplicationsMimirSelect());
|
||||||
|
await userEvents.type(getTreeSearch(), 'grafana');
|
||||||
|
await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(3));
|
||||||
|
await userEvents.clear(getTreeSearch());
|
||||||
|
await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(4));
|
||||||
|
expect(queryPersistedApplicationsMimirTitle()).not.toBeInTheDocument();
|
||||||
|
expect(queryPersistedApplicationsGrafanaTitle()).not.toBeInTheDocument();
|
||||||
|
expect(getResultApplicationsMimirTitle()).toBeInTheDocument();
|
||||||
|
expect(getResultApplicationsGrafanaTitle()).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Persists nodes from search', async () => {
|
||||||
|
await userEvents.click(getSelectorInput());
|
||||||
|
await userEvents.click(getResultApplicationsExpand());
|
||||||
|
await userEvents.type(getTreeSearch(), 'mimir');
|
||||||
|
await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(3));
|
||||||
|
await userEvents.click(getResultApplicationsMimirSelect());
|
||||||
|
await userEvents.type(getTreeSearch(), 'unknown');
|
||||||
|
await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(4));
|
||||||
|
expect(getPersistedApplicationsMimirTitle()).toBeInTheDocument();
|
||||||
|
await userEvents.clear(getTreeSearch());
|
||||||
|
await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(5));
|
||||||
|
expect(getResultApplicationsMimirTitle()).toBeInTheDocument();
|
||||||
|
expect(getResultApplicationsGrafanaTitle()).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Selects a persisted scope', async () => {
|
||||||
|
await userEvents.click(getSelectorInput());
|
||||||
|
await userEvents.click(getResultApplicationsExpand());
|
||||||
|
await userEvents.click(getResultApplicationsMimirSelect());
|
||||||
|
await userEvents.type(getTreeSearch(), 'grafana');
|
||||||
|
await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(3));
|
||||||
|
await userEvents.click(getResultApplicationsGrafanaSelect());
|
||||||
|
await userEvents.click(getSelectorApply());
|
||||||
|
expect(getSelectorInput().value).toBe('Mimir, Grafana');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Deselects a persisted scope', async () => {
|
||||||
|
await userEvents.click(getSelectorInput());
|
||||||
|
await userEvents.click(getResultApplicationsExpand());
|
||||||
|
await userEvents.click(getResultApplicationsMimirSelect());
|
||||||
|
await userEvents.type(getTreeSearch(), 'grafana');
|
||||||
|
await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(3));
|
||||||
|
await userEvents.click(getResultApplicationsGrafanaSelect());
|
||||||
|
await userEvents.click(getSelectorApply());
|
||||||
|
expect(getSelectorInput().value).toBe('Mimir, Grafana');
|
||||||
|
await userEvents.click(getSelectorInput());
|
||||||
|
await userEvents.click(getPersistedApplicationsMimirSelect());
|
||||||
|
await userEvents.click(getSelectorApply());
|
||||||
|
expect(getSelectorInput().value).toBe('Grafana');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Shows the proper headline', async () => {
|
||||||
|
await userEvents.click(getSelectorInput());
|
||||||
|
expect(getTreeHeadline()).toHaveTextContent('Recommended');
|
||||||
|
await userEvents.type(getTreeSearch(), 'Applications');
|
||||||
|
await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(2));
|
||||||
|
expect(getTreeHeadline()).toHaveTextContent('Results');
|
||||||
|
await userEvents.type(getTreeSearch(), 'unknown');
|
||||||
|
await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(3));
|
||||||
|
expect(getTreeHeadline()).toHaveTextContent('No results found for your query');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Selector', () => {
|
||||||
|
it('Opens', async () => {
|
||||||
|
await userEvents.click(getSelectorInput());
|
||||||
|
expect(getSelectorApply()).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Fetches scope details on save', async () => {
|
||||||
|
await userEvents.click(getSelectorInput());
|
||||||
|
await userEvents.click(getResultCloudSelect());
|
||||||
|
await userEvents.click(getSelectorApply());
|
||||||
|
await waitFor(() => expect(fetchSelectedScopesSpy).toHaveBeenCalled());
|
||||||
|
expect(getClosestScopesFacade(dashboardScene)?.value).toEqual(
|
||||||
|
mocksScopes.filter(({ metadata: { name } }) => name === 'cloud')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Does not save the scopes on close', async () => {
|
||||||
|
await userEvents.click(getSelectorInput());
|
||||||
|
await userEvents.click(getResultCloudSelect());
|
||||||
|
await userEvents.click(getSelectorCancel());
|
||||||
|
await waitFor(() => expect(fetchSelectedScopesSpy).not.toHaveBeenCalled());
|
||||||
|
expect(getClosestScopesFacade(dashboardScene)?.value).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Shows selected scopes', async () => {
|
||||||
|
await userEvents.click(getSelectorInput());
|
||||||
|
await userEvents.click(getResultCloudSelect());
|
||||||
|
await userEvents.click(getSelectorApply());
|
||||||
|
expect(getSelectorInput().value).toEqual('Cloud');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Dashboards list', () => {
|
||||||
|
it('Toggles expanded state', async () => {
|
||||||
|
await userEvents.click(getDashboardsExpand());
|
||||||
|
expect(getNotFoundNoScopes()).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Does not fetch dashboards list when the list is not expanded', async () => {
|
||||||
|
await userEvents.click(getSelectorInput());
|
||||||
|
await userEvents.click(getResultApplicationsExpand());
|
||||||
|
await userEvents.click(getResultApplicationsMimirSelect());
|
||||||
|
await userEvents.click(getSelectorApply());
|
||||||
|
await waitFor(() => expect(fetchDashboardsSpy).not.toHaveBeenCalled());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Fetches dashboards list when the list is expanded', async () => {
|
||||||
|
await userEvents.click(getDashboardsExpand());
|
||||||
|
await userEvents.click(getSelectorInput());
|
||||||
|
await userEvents.click(getResultApplicationsExpand());
|
||||||
|
await userEvents.click(getResultApplicationsMimirSelect());
|
||||||
|
await userEvents.click(getSelectorApply());
|
||||||
|
await waitFor(() => expect(fetchDashboardsSpy).toHaveBeenCalled());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Fetches dashboards list when the list is expanded after scope selection', async () => {
|
||||||
|
await userEvents.click(getSelectorInput());
|
||||||
|
await userEvents.click(getResultApplicationsExpand());
|
||||||
|
await userEvents.click(getResultApplicationsMimirSelect());
|
||||||
|
await userEvents.click(getSelectorApply());
|
||||||
|
await userEvents.click(getDashboardsExpand());
|
||||||
|
await waitFor(() => expect(fetchDashboardsSpy).toHaveBeenCalled());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Shows dashboards for multiple scopes', async () => {
|
||||||
|
await userEvents.click(getDashboardsExpand());
|
||||||
|
await userEvents.click(getSelectorInput());
|
||||||
|
await userEvents.click(getResultApplicationsExpand());
|
||||||
|
await userEvents.click(getResultApplicationsGrafanaSelect());
|
||||||
|
await userEvents.click(getSelectorApply());
|
||||||
|
await userEvents.click(getDashboardFolderExpand('General'));
|
||||||
|
await userEvents.click(getDashboardFolderExpand('Observability'));
|
||||||
|
await userEvents.click(getDashboardFolderExpand('Usage'));
|
||||||
|
expect(queryDashboardFolderExpand('Components')).not.toBeInTheDocument();
|
||||||
|
expect(queryDashboardFolderExpand('Investigations')).not.toBeInTheDocument();
|
||||||
|
expect(getDashboard('general-data-sources')).toBeInTheDocument();
|
||||||
|
expect(getDashboard('general-usage')).toBeInTheDocument();
|
||||||
|
expect(getDashboard('observability-backend-errors')).toBeInTheDocument();
|
||||||
|
expect(getDashboard('observability-backend-logs')).toBeInTheDocument();
|
||||||
|
expect(getDashboard('observability-frontend-errors')).toBeInTheDocument();
|
||||||
|
expect(getDashboard('observability-frontend-logs')).toBeInTheDocument();
|
||||||
|
expect(getDashboard('usage-data-sources')).toBeInTheDocument();
|
||||||
|
expect(getDashboard('usage-stats')).toBeInTheDocument();
|
||||||
|
expect(getDashboard('usage-usage-overview')).toBeInTheDocument();
|
||||||
|
expect(getDashboard('frontend')).toBeInTheDocument();
|
||||||
|
expect(getDashboard('overview')).toBeInTheDocument();
|
||||||
|
expect(getDashboard('stats')).toBeInTheDocument();
|
||||||
|
expect(queryDashboard('multiple3-datasource-errors')).not.toBeInTheDocument();
|
||||||
|
expect(queryDashboard('multiple4-datasource-logs')).not.toBeInTheDocument();
|
||||||
|
expect(queryDashboard('multiple0-ingester')).not.toBeInTheDocument();
|
||||||
|
expect(queryDashboard('multiple1-distributor')).not.toBeInTheDocument();
|
||||||
|
expect(queryDashboard('multiple2-compacter')).not.toBeInTheDocument();
|
||||||
|
expect(queryDashboard('another-stats')).not.toBeInTheDocument();
|
||||||
|
await userEvents.click(getSelectorInput());
|
||||||
|
await userEvents.click(getResultApplicationsMimirSelect());
|
||||||
|
await userEvents.click(getSelectorApply());
|
||||||
|
await userEvents.click(getDashboardFolderExpand('General'));
|
||||||
|
await userEvents.click(getDashboardFolderExpand('Observability'));
|
||||||
|
await userEvents.click(getDashboardFolderExpand('Usage'));
|
||||||
|
await userEvents.click(getDashboardFolderExpand('Components'));
|
||||||
|
await userEvents.click(getDashboardFolderExpand('Investigations'));
|
||||||
|
expect(getDashboard('general-data-sources')).toBeInTheDocument();
|
||||||
|
expect(getDashboard('general-usage')).toBeInTheDocument();
|
||||||
|
expect(getDashboard('observability-backend-errors')).toBeInTheDocument();
|
||||||
|
expect(getDashboard('observability-backend-logs')).toBeInTheDocument();
|
||||||
|
expect(getDashboard('observability-frontend-errors')).toBeInTheDocument();
|
||||||
|
expect(getDashboard('observability-frontend-logs')).toBeInTheDocument();
|
||||||
|
expect(getDashboard('usage-data-sources')).toBeInTheDocument();
|
||||||
|
expect(getDashboard('usage-stats')).toBeInTheDocument();
|
||||||
|
expect(getDashboard('usage-usage-overview')).toBeInTheDocument();
|
||||||
|
expect(getDashboard('frontend')).toBeInTheDocument();
|
||||||
|
expect(getDashboard('overview')).toBeInTheDocument();
|
||||||
|
expect(getDashboard('stats')).toBeInTheDocument();
|
||||||
|
expect(queryAllDashboard('multiple3-datasource-errors')).toHaveLength(2);
|
||||||
|
expect(queryAllDashboard('multiple4-datasource-logs')).toHaveLength(2);
|
||||||
|
expect(queryAllDashboard('multiple0-ingester')).toHaveLength(2);
|
||||||
|
expect(queryAllDashboard('multiple1-distributor')).toHaveLength(2);
|
||||||
|
expect(queryAllDashboard('multiple2-compacter')).toHaveLength(2);
|
||||||
|
expect(getDashboard('another-stats')).toBeInTheDocument();
|
||||||
|
await userEvents.click(getSelectorInput());
|
||||||
|
await userEvents.click(getResultApplicationsMimirSelect());
|
||||||
|
await userEvents.click(getSelectorApply());
|
||||||
|
await userEvents.click(getDashboardFolderExpand('General'));
|
||||||
|
await userEvents.click(getDashboardFolderExpand('Observability'));
|
||||||
|
await userEvents.click(getDashboardFolderExpand('Usage'));
|
||||||
|
expect(queryDashboardFolderExpand('Components')).not.toBeInTheDocument();
|
||||||
|
expect(queryDashboardFolderExpand('Investigations')).not.toBeInTheDocument();
|
||||||
|
expect(getDashboard('general-data-sources')).toBeInTheDocument();
|
||||||
|
expect(getDashboard('general-usage')).toBeInTheDocument();
|
||||||
|
expect(getDashboard('observability-backend-errors')).toBeInTheDocument();
|
||||||
|
expect(getDashboard('observability-backend-logs')).toBeInTheDocument();
|
||||||
|
expect(getDashboard('observability-frontend-errors')).toBeInTheDocument();
|
||||||
|
expect(getDashboard('observability-frontend-logs')).toBeInTheDocument();
|
||||||
|
expect(getDashboard('usage-data-sources')).toBeInTheDocument();
|
||||||
|
expect(getDashboard('usage-stats')).toBeInTheDocument();
|
||||||
|
expect(getDashboard('usage-usage-overview')).toBeInTheDocument();
|
||||||
|
expect(getDashboard('frontend')).toBeInTheDocument();
|
||||||
|
expect(getDashboard('overview')).toBeInTheDocument();
|
||||||
|
expect(getDashboard('stats')).toBeInTheDocument();
|
||||||
|
expect(queryDashboard('multiple3-datasource-errors')).not.toBeInTheDocument();
|
||||||
|
expect(queryDashboard('multiple4-datasource-logs')).not.toBeInTheDocument();
|
||||||
|
expect(queryDashboard('multiple0-ingester')).not.toBeInTheDocument();
|
||||||
|
expect(queryDashboard('multiple1-distributor')).not.toBeInTheDocument();
|
||||||
|
expect(queryDashboard('multiple2-compacter')).not.toBeInTheDocument();
|
||||||
|
expect(queryDashboard('another-stats')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Filters the dashboards list for dashboards', async () => {
|
||||||
|
await userEvents.click(getDashboardsExpand());
|
||||||
|
await userEvents.click(getSelectorInput());
|
||||||
|
await userEvents.click(getResultApplicationsExpand());
|
||||||
|
await userEvents.click(getResultApplicationsGrafanaSelect());
|
||||||
|
await userEvents.click(getSelectorApply());
|
||||||
|
await userEvents.click(getDashboardFolderExpand('General'));
|
||||||
|
await userEvents.click(getDashboardFolderExpand('Observability'));
|
||||||
|
await userEvents.click(getDashboardFolderExpand('Usage'));
|
||||||
|
expect(getDashboard('general-data-sources')).toBeInTheDocument();
|
||||||
|
expect(getDashboard('general-usage')).toBeInTheDocument();
|
||||||
|
expect(getDashboard('observability-backend-errors')).toBeInTheDocument();
|
||||||
|
expect(getDashboard('observability-backend-logs')).toBeInTheDocument();
|
||||||
|
expect(getDashboard('observability-frontend-errors')).toBeInTheDocument();
|
||||||
|
expect(getDashboard('observability-frontend-logs')).toBeInTheDocument();
|
||||||
|
expect(getDashboard('usage-data-sources')).toBeInTheDocument();
|
||||||
|
expect(getDashboard('usage-stats')).toBeInTheDocument();
|
||||||
|
expect(getDashboard('usage-usage-overview')).toBeInTheDocument();
|
||||||
|
expect(getDashboard('frontend')).toBeInTheDocument();
|
||||||
|
expect(getDashboard('overview')).toBeInTheDocument();
|
||||||
|
expect(getDashboard('stats')).toBeInTheDocument();
|
||||||
|
await userEvents.type(getDashboardsSearch(), 'Stats');
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(queryDashboard('general-data-sources')).not.toBeInTheDocument();
|
||||||
|
expect(queryDashboard('general-usage')).not.toBeInTheDocument();
|
||||||
|
expect(queryDashboard('observability-backend-errors')).not.toBeInTheDocument();
|
||||||
|
expect(queryDashboard('observability-backend-logs')).not.toBeInTheDocument();
|
||||||
|
expect(queryDashboard('observability-frontend-errors')).not.toBeInTheDocument();
|
||||||
|
expect(queryDashboard('observability-frontend-logs')).not.toBeInTheDocument();
|
||||||
|
expect(queryDashboard('usage-data-sources')).not.toBeInTheDocument();
|
||||||
|
expect(getDashboard('usage-stats')).toBeInTheDocument();
|
||||||
|
expect(queryDashboard('usage-usage-overview')).not.toBeInTheDocument();
|
||||||
|
expect(queryDashboard('frontend')).not.toBeInTheDocument();
|
||||||
|
expect(queryDashboard('overview')).not.toBeInTheDocument();
|
||||||
|
expect(getDashboard('stats')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Filters the dashboards list for folders', async () => {
|
||||||
|
await userEvents.click(getDashboardsExpand());
|
||||||
|
await userEvents.click(getSelectorInput());
|
||||||
|
await userEvents.click(getResultApplicationsExpand());
|
||||||
|
await userEvents.click(getResultApplicationsGrafanaSelect());
|
||||||
|
await userEvents.click(getSelectorApply());
|
||||||
|
await userEvents.click(getDashboardFolderExpand('General'));
|
||||||
|
await userEvents.click(getDashboardFolderExpand('Observability'));
|
||||||
|
await userEvents.click(getDashboardFolderExpand('Usage'));
|
||||||
|
expect(getDashboard('general-data-sources')).toBeInTheDocument();
|
||||||
|
expect(getDashboard('general-usage')).toBeInTheDocument();
|
||||||
|
expect(getDashboard('observability-backend-errors')).toBeInTheDocument();
|
||||||
|
expect(getDashboard('observability-backend-logs')).toBeInTheDocument();
|
||||||
|
expect(getDashboard('observability-frontend-errors')).toBeInTheDocument();
|
||||||
|
expect(getDashboard('observability-frontend-logs')).toBeInTheDocument();
|
||||||
|
expect(getDashboard('usage-data-sources')).toBeInTheDocument();
|
||||||
|
expect(getDashboard('usage-stats')).toBeInTheDocument();
|
||||||
|
expect(getDashboard('usage-usage-overview')).toBeInTheDocument();
|
||||||
|
expect(getDashboard('frontend')).toBeInTheDocument();
|
||||||
|
expect(getDashboard('overview')).toBeInTheDocument();
|
||||||
|
expect(getDashboard('stats')).toBeInTheDocument();
|
||||||
|
await userEvents.type(getDashboardsSearch(), 'Usage');
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(queryDashboard('general-data-sources')).not.toBeInTheDocument();
|
||||||
|
expect(getDashboard('general-usage')).toBeInTheDocument();
|
||||||
|
expect(queryDashboard('observability-backend-errors')).not.toBeInTheDocument();
|
||||||
|
expect(queryDashboard('observability-backend-logs')).not.toBeInTheDocument();
|
||||||
|
expect(queryDashboard('observability-frontend-errors')).not.toBeInTheDocument();
|
||||||
|
expect(queryDashboard('observability-frontend-logs')).not.toBeInTheDocument();
|
||||||
|
expect(getDashboard('usage-data-sources')).toBeInTheDocument();
|
||||||
|
expect(getDashboard('usage-stats')).toBeInTheDocument();
|
||||||
|
expect(getDashboard('usage-usage-overview')).toBeInTheDocument();
|
||||||
|
expect(queryDashboard('frontend')).not.toBeInTheDocument();
|
||||||
|
expect(queryDashboard('overview')).not.toBeInTheDocument();
|
||||||
|
expect(queryDashboard('stats')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Deduplicates the dashboards list', async () => {
|
||||||
|
await userEvents.click(getDashboardsExpand());
|
||||||
|
await userEvents.click(getSelectorInput());
|
||||||
|
await userEvents.click(getResultApplicationsExpand());
|
||||||
|
await userEvents.click(getResultApplicationsCloudExpand());
|
||||||
|
await userEvents.click(getResultApplicationsCloudDevSelect());
|
||||||
|
await userEvents.click(getResultApplicationsCloudOpsSelect());
|
||||||
|
await userEvents.click(getSelectorApply());
|
||||||
|
await userEvents.click(getDashboardFolderExpand('Cardinality Management'));
|
||||||
|
await userEvents.click(getDashboardFolderExpand('Usage Insights'));
|
||||||
|
expect(queryAllDashboard('cardinality-management-labels')).toHaveLength(1);
|
||||||
|
expect(queryAllDashboard('cardinality-management-metrics')).toHaveLength(1);
|
||||||
|
expect(queryAllDashboard('cardinality-management-overview')).toHaveLength(1);
|
||||||
|
expect(queryAllDashboard('usage-insights-alertmanager')).toHaveLength(1);
|
||||||
|
expect(queryAllDashboard('usage-insights-data-sources')).toHaveLength(1);
|
||||||
|
expect(queryAllDashboard('usage-insights-metrics-ingestion')).toHaveLength(1);
|
||||||
|
expect(queryAllDashboard('usage-insights-overview')).toHaveLength(1);
|
||||||
|
expect(queryAllDashboard('usage-insights-query-errors')).toHaveLength(1);
|
||||||
|
expect(queryAllDashboard('billing-usage')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Shows a proper message when no scopes are selected', async () => {
|
||||||
|
await userEvents.click(getDashboardsExpand());
|
||||||
|
expect(getNotFoundNoScopes()).toBeInTheDocument();
|
||||||
|
expect(queryDashboardsSearch()).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Does not show the input when there are no dashboards found for scope', async () => {
|
||||||
|
await userEvents.click(getDashboardsExpand());
|
||||||
|
await userEvents.click(getSelectorInput());
|
||||||
|
await userEvents.click(getResultCloudSelect());
|
||||||
|
await userEvents.click(getSelectorApply());
|
||||||
|
expect(getNotFoundForScope()).toBeInTheDocument();
|
||||||
|
expect(queryDashboardsSearch()).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Shows the input and a message when there are no dashboards found for filter', async () => {
|
||||||
|
await userEvents.click(getDashboardsExpand());
|
||||||
|
await userEvents.click(getSelectorInput());
|
||||||
|
await userEvents.click(getResultApplicationsExpand());
|
||||||
|
await userEvents.click(getResultApplicationsMimirSelect());
|
||||||
|
await userEvents.click(getSelectorApply());
|
||||||
|
await userEvents.type(getDashboardsSearch(), 'unknown');
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(queryDashboardsSearch()).toBeInTheDocument();
|
||||||
|
expect(getNotFoundForFilter()).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
await userEvents.click(getNotFoundForFilterClear());
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(getDashboardsSearch().value).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('View mode', () => {
|
||||||
|
it('Enters view mode', async () => {
|
||||||
|
await act(async () => dashboardScene.onEnterEditMode());
|
||||||
|
expect(scopesSelectorScene?.state?.isReadOnly).toEqual(true);
|
||||||
|
expect(scopesDashboardsScene?.state?.isPanelOpened).toEqual(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Closes selector on enter', async () => {
|
||||||
|
await userEvents.click(getSelectorInput());
|
||||||
|
await act(async () => dashboardScene.onEnterEditMode());
|
||||||
|
expect(querySelectorApply()).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Closes dashboards list on enter', async () => {
|
||||||
|
await userEvents.click(getDashboardsExpand());
|
||||||
|
await act(async () => dashboardScene.onEnterEditMode());
|
||||||
|
expect(queryDashboardsContainer()).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Does not open selector when view mode is active', async () => {
|
||||||
|
await act(async () => dashboardScene.onEnterEditMode());
|
||||||
|
await userEvents.click(getSelectorInput());
|
||||||
|
expect(querySelectorApply()).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Disables the expand button when view mode is active', async () => {
|
||||||
|
await act(async () => dashboardScene.onEnterEditMode());
|
||||||
|
expect(getDashboardsExpand()).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Enrichers', () => {
|
||||||
|
it('Data requests', async () => {
|
||||||
|
await userEvents.click(getSelectorInput());
|
||||||
|
await userEvents.click(getResultApplicationsExpand());
|
||||||
|
await userEvents.click(getResultApplicationsGrafanaSelect());
|
||||||
|
await userEvents.click(getSelectorApply());
|
||||||
|
await waitFor(() => {
|
||||||
|
const queryRunner = sceneGraph.findObject(dashboardScene, (o) => o.state.key === 'data-query-runner')!;
|
||||||
|
expect(dashboardScene.enrichDataRequest(queryRunner).scopes).toEqual(
|
||||||
|
mocksScopes.filter(({ metadata: { name } }) => name === 'grafana')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvents.click(getSelectorInput());
|
||||||
|
await userEvents.click(getResultApplicationsMimirSelect());
|
||||||
|
await userEvents.click(getSelectorApply());
|
||||||
|
await waitFor(() => {
|
||||||
|
const queryRunner = sceneGraph.findObject(dashboardScene, (o) => o.state.key === 'data-query-runner')!;
|
||||||
|
expect(dashboardScene.enrichDataRequest(queryRunner).scopes).toEqual(
|
||||||
|
mocksScopes.filter(({ metadata: { name } }) => name === 'grafana' || name === 'mimir')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvents.click(getSelectorInput());
|
||||||
|
await userEvents.click(getResultApplicationsGrafanaSelect());
|
||||||
|
await userEvents.click(getSelectorApply());
|
||||||
|
await waitFor(() => {
|
||||||
|
const queryRunner = sceneGraph.findObject(dashboardScene, (o) => o.state.key === 'data-query-runner')!;
|
||||||
|
expect(dashboardScene.enrichDataRequest(queryRunner).scopes).toEqual(
|
||||||
|
mocksScopes.filter(({ metadata: { name } }) => name === 'mimir')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Filters requests', async () => {
|
||||||
|
await userEvents.click(getSelectorInput());
|
||||||
|
await userEvents.click(getResultApplicationsExpand());
|
||||||
|
await userEvents.click(getResultApplicationsGrafanaSelect());
|
||||||
|
await userEvents.click(getSelectorApply());
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(dashboardScene.enrichFiltersRequest().scopes).toEqual(
|
||||||
|
mocksScopes.filter(({ metadata: { name } }) => name === 'grafana')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvents.click(getSelectorInput());
|
||||||
|
await userEvents.click(getResultApplicationsMimirSelect());
|
||||||
|
await userEvents.click(getSelectorApply());
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(dashboardScene.enrichFiltersRequest().scopes).toEqual(
|
||||||
|
mocksScopes.filter(({ metadata: { name } }) => name === 'grafana' || name === 'mimir')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvents.click(getSelectorInput());
|
||||||
|
await userEvents.click(getResultApplicationsGrafanaSelect());
|
||||||
|
await userEvents.click(getSelectorApply());
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(dashboardScene.enrichFiltersRequest().scopes).toEqual(
|
||||||
|
mocksScopes.filter(({ metadata: { name } }) => name === 'mimir')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
366
public/app/features/scopes/tests/utils.test.ts
Normal file
366
public/app/features/scopes/tests/utils.test.ts
Normal file
@ -0,0 +1,366 @@
|
|||||||
|
import { filterFolders, groupDashboards } from '../internal/utils';
|
||||||
|
|
||||||
|
import {
|
||||||
|
alternativeDashboardWithRootFolder,
|
||||||
|
alternativeDashboardWithTwoFolders,
|
||||||
|
dashboardWithOneFolder,
|
||||||
|
dashboardWithoutFolder,
|
||||||
|
dashboardWithRootFolder,
|
||||||
|
dashboardWithRootFolderAndOtherFolder,
|
||||||
|
dashboardWithTwoFolders,
|
||||||
|
} from './utils/mocks';
|
||||||
|
|
||||||
|
describe('Scopes', () => {
|
||||||
|
describe('Utils', () => {
|
||||||
|
describe('groupDashboards', () => {
|
||||||
|
it('Assigns dashboards without groups to root folder', () => {
|
||||||
|
expect(groupDashboards([dashboardWithoutFolder])).toEqual({
|
||||||
|
'': {
|
||||||
|
title: '',
|
||||||
|
isExpanded: true,
|
||||||
|
folders: {},
|
||||||
|
dashboards: {
|
||||||
|
[dashboardWithoutFolder.spec.dashboard]: {
|
||||||
|
dashboard: dashboardWithoutFolder.spec.dashboard,
|
||||||
|
dashboardTitle: dashboardWithoutFolder.spec.dashboardTitle,
|
||||||
|
items: [dashboardWithoutFolder],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Assigns dashboards with root group to root folder', () => {
|
||||||
|
expect(groupDashboards([dashboardWithRootFolder])).toEqual({
|
||||||
|
'': {
|
||||||
|
title: '',
|
||||||
|
isExpanded: true,
|
||||||
|
folders: {},
|
||||||
|
dashboards: {
|
||||||
|
[dashboardWithRootFolder.spec.dashboard]: {
|
||||||
|
dashboard: dashboardWithRootFolder.spec.dashboard,
|
||||||
|
dashboardTitle: dashboardWithRootFolder.spec.dashboardTitle,
|
||||||
|
items: [dashboardWithRootFolder],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Merges folders from multiple dashboards', () => {
|
||||||
|
expect(groupDashboards([dashboardWithOneFolder, dashboardWithTwoFolders])).toEqual({
|
||||||
|
'': {
|
||||||
|
title: '',
|
||||||
|
isExpanded: true,
|
||||||
|
folders: {
|
||||||
|
'Folder 1': {
|
||||||
|
title: 'Folder 1',
|
||||||
|
isExpanded: false,
|
||||||
|
folders: {},
|
||||||
|
dashboards: {
|
||||||
|
[dashboardWithOneFolder.spec.dashboard]: {
|
||||||
|
dashboard: dashboardWithOneFolder.spec.dashboard,
|
||||||
|
dashboardTitle: dashboardWithOneFolder.spec.dashboardTitle,
|
||||||
|
items: [dashboardWithOneFolder],
|
||||||
|
},
|
||||||
|
[dashboardWithTwoFolders.spec.dashboard]: {
|
||||||
|
dashboard: dashboardWithTwoFolders.spec.dashboard,
|
||||||
|
dashboardTitle: dashboardWithTwoFolders.spec.dashboardTitle,
|
||||||
|
items: [dashboardWithTwoFolders],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'Folder 2': {
|
||||||
|
title: 'Folder 2',
|
||||||
|
isExpanded: false,
|
||||||
|
folders: {},
|
||||||
|
dashboards: {
|
||||||
|
[dashboardWithTwoFolders.spec.dashboard]: {
|
||||||
|
dashboard: dashboardWithTwoFolders.spec.dashboard,
|
||||||
|
dashboardTitle: dashboardWithTwoFolders.spec.dashboardTitle,
|
||||||
|
items: [dashboardWithTwoFolders],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
dashboards: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Merges scopes from multiple dashboards', () => {
|
||||||
|
expect(groupDashboards([dashboardWithTwoFolders, alternativeDashboardWithTwoFolders])).toEqual({
|
||||||
|
'': {
|
||||||
|
title: '',
|
||||||
|
isExpanded: true,
|
||||||
|
folders: {
|
||||||
|
'Folder 1': {
|
||||||
|
title: 'Folder 1',
|
||||||
|
isExpanded: false,
|
||||||
|
folders: {},
|
||||||
|
dashboards: {
|
||||||
|
[dashboardWithTwoFolders.spec.dashboard]: {
|
||||||
|
dashboard: dashboardWithTwoFolders.spec.dashboard,
|
||||||
|
dashboardTitle: dashboardWithTwoFolders.spec.dashboardTitle,
|
||||||
|
items: [dashboardWithTwoFolders, alternativeDashboardWithTwoFolders],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'Folder 2': {
|
||||||
|
title: 'Folder 2',
|
||||||
|
isExpanded: false,
|
||||||
|
folders: {},
|
||||||
|
dashboards: {
|
||||||
|
[dashboardWithTwoFolders.spec.dashboard]: {
|
||||||
|
dashboard: dashboardWithTwoFolders.spec.dashboard,
|
||||||
|
dashboardTitle: dashboardWithTwoFolders.spec.dashboardTitle,
|
||||||
|
items: [dashboardWithTwoFolders, alternativeDashboardWithTwoFolders],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
dashboards: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Matches snapshot', () => {
|
||||||
|
expect(
|
||||||
|
groupDashboards([
|
||||||
|
dashboardWithoutFolder,
|
||||||
|
dashboardWithOneFolder,
|
||||||
|
dashboardWithTwoFolders,
|
||||||
|
alternativeDashboardWithTwoFolders,
|
||||||
|
dashboardWithRootFolder,
|
||||||
|
alternativeDashboardWithRootFolder,
|
||||||
|
dashboardWithRootFolderAndOtherFolder,
|
||||||
|
])
|
||||||
|
).toEqual({
|
||||||
|
'': {
|
||||||
|
dashboards: {
|
||||||
|
[dashboardWithRootFolderAndOtherFolder.spec.dashboard]: {
|
||||||
|
dashboard: dashboardWithRootFolderAndOtherFolder.spec.dashboard,
|
||||||
|
dashboardTitle: dashboardWithRootFolderAndOtherFolder.spec.dashboardTitle,
|
||||||
|
items: [dashboardWithRootFolderAndOtherFolder],
|
||||||
|
},
|
||||||
|
[dashboardWithRootFolder.spec.dashboard]: {
|
||||||
|
dashboard: dashboardWithRootFolder.spec.dashboard,
|
||||||
|
dashboardTitle: dashboardWithRootFolder.spec.dashboardTitle,
|
||||||
|
items: [dashboardWithRootFolder, alternativeDashboardWithRootFolder],
|
||||||
|
},
|
||||||
|
[dashboardWithoutFolder.spec.dashboard]: {
|
||||||
|
dashboard: dashboardWithoutFolder.spec.dashboard,
|
||||||
|
dashboardTitle: dashboardWithoutFolder.spec.dashboardTitle,
|
||||||
|
items: [dashboardWithoutFolder],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
folders: {
|
||||||
|
'Folder 1': {
|
||||||
|
dashboards: {
|
||||||
|
[dashboardWithOneFolder.spec.dashboard]: {
|
||||||
|
dashboard: dashboardWithOneFolder.spec.dashboard,
|
||||||
|
dashboardTitle: dashboardWithOneFolder.spec.dashboardTitle,
|
||||||
|
items: [dashboardWithOneFolder],
|
||||||
|
},
|
||||||
|
[dashboardWithTwoFolders.spec.dashboard]: {
|
||||||
|
dashboard: dashboardWithTwoFolders.spec.dashboard,
|
||||||
|
dashboardTitle: dashboardWithTwoFolders.spec.dashboardTitle,
|
||||||
|
items: [dashboardWithTwoFolders, alternativeDashboardWithTwoFolders],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
folders: {},
|
||||||
|
isExpanded: false,
|
||||||
|
title: 'Folder 1',
|
||||||
|
},
|
||||||
|
'Folder 2': {
|
||||||
|
dashboards: {
|
||||||
|
[dashboardWithTwoFolders.spec.dashboard]: {
|
||||||
|
dashboard: dashboardWithTwoFolders.spec.dashboard,
|
||||||
|
dashboardTitle: dashboardWithTwoFolders.spec.dashboardTitle,
|
||||||
|
items: [dashboardWithTwoFolders, alternativeDashboardWithTwoFolders],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
folders: {},
|
||||||
|
isExpanded: false,
|
||||||
|
title: 'Folder 2',
|
||||||
|
},
|
||||||
|
'Folder 3': {
|
||||||
|
dashboards: {
|
||||||
|
[dashboardWithRootFolderAndOtherFolder.spec.dashboard]: {
|
||||||
|
dashboard: dashboardWithRootFolderAndOtherFolder.spec.dashboard,
|
||||||
|
dashboardTitle: dashboardWithRootFolderAndOtherFolder.spec.dashboardTitle,
|
||||||
|
items: [dashboardWithRootFolderAndOtherFolder],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
folders: {},
|
||||||
|
isExpanded: false,
|
||||||
|
title: 'Folder 3',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
isExpanded: true,
|
||||||
|
title: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('filterFolders', () => {
|
||||||
|
it('Shows folders matching criteria', () => {
|
||||||
|
expect(
|
||||||
|
filterFolders(
|
||||||
|
{
|
||||||
|
'': {
|
||||||
|
title: '',
|
||||||
|
isExpanded: true,
|
||||||
|
folders: {
|
||||||
|
'Folder 1': {
|
||||||
|
title: 'Folder 1',
|
||||||
|
isExpanded: false,
|
||||||
|
folders: {},
|
||||||
|
dashboards: {
|
||||||
|
'Dashboard ID': {
|
||||||
|
dashboard: 'Dashboard ID',
|
||||||
|
dashboardTitle: 'Dashboard Title',
|
||||||
|
items: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'Folder 2': {
|
||||||
|
title: 'Folder 2',
|
||||||
|
isExpanded: true,
|
||||||
|
folders: {},
|
||||||
|
dashboards: {
|
||||||
|
'Dashboard ID': {
|
||||||
|
dashboard: 'Dashboard ID',
|
||||||
|
dashboardTitle: 'Dashboard Title',
|
||||||
|
items: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
dashboards: {
|
||||||
|
'Dashboard ID': {
|
||||||
|
dashboard: 'Dashboard ID',
|
||||||
|
dashboardTitle: 'Dashboard Title',
|
||||||
|
items: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'Folder'
|
||||||
|
)
|
||||||
|
).toEqual({
|
||||||
|
'': {
|
||||||
|
title: '',
|
||||||
|
isExpanded: true,
|
||||||
|
folders: {
|
||||||
|
'Folder 1': {
|
||||||
|
title: 'Folder 1',
|
||||||
|
isExpanded: true,
|
||||||
|
folders: {},
|
||||||
|
dashboards: {
|
||||||
|
'Dashboard ID': {
|
||||||
|
dashboard: 'Dashboard ID',
|
||||||
|
dashboardTitle: 'Dashboard Title',
|
||||||
|
items: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'Folder 2': {
|
||||||
|
title: 'Folder 2',
|
||||||
|
isExpanded: true,
|
||||||
|
folders: {},
|
||||||
|
dashboards: {
|
||||||
|
'Dashboard ID': {
|
||||||
|
dashboard: 'Dashboard ID',
|
||||||
|
dashboardTitle: 'Dashboard Title',
|
||||||
|
items: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
dashboards: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Shows dashboards matching criteria', () => {
|
||||||
|
expect(
|
||||||
|
filterFolders(
|
||||||
|
{
|
||||||
|
'': {
|
||||||
|
title: '',
|
||||||
|
isExpanded: true,
|
||||||
|
folders: {
|
||||||
|
'Folder 1': {
|
||||||
|
title: 'Folder 1',
|
||||||
|
isExpanded: false,
|
||||||
|
folders: {},
|
||||||
|
dashboards: {
|
||||||
|
'Dashboard ID': {
|
||||||
|
dashboard: 'Dashboard ID',
|
||||||
|
dashboardTitle: 'Dashboard Title',
|
||||||
|
items: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'Folder 2': {
|
||||||
|
title: 'Folder 2',
|
||||||
|
isExpanded: true,
|
||||||
|
folders: {},
|
||||||
|
dashboards: {
|
||||||
|
'Random ID': {
|
||||||
|
dashboard: 'Random ID',
|
||||||
|
dashboardTitle: 'Random Title',
|
||||||
|
items: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
dashboards: {
|
||||||
|
'Dashboard ID': {
|
||||||
|
dashboard: 'Dashboard ID',
|
||||||
|
dashboardTitle: 'Dashboard Title',
|
||||||
|
items: [],
|
||||||
|
},
|
||||||
|
'Random ID': {
|
||||||
|
dashboard: 'Random ID',
|
||||||
|
dashboardTitle: 'Random Title',
|
||||||
|
items: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'dash'
|
||||||
|
)
|
||||||
|
).toEqual({
|
||||||
|
'': {
|
||||||
|
title: '',
|
||||||
|
isExpanded: true,
|
||||||
|
folders: {
|
||||||
|
'Folder 1': {
|
||||||
|
title: 'Folder 1',
|
||||||
|
isExpanded: true,
|
||||||
|
folders: {},
|
||||||
|
dashboards: {
|
||||||
|
'Dashboard ID': {
|
||||||
|
dashboard: 'Dashboard ID',
|
||||||
|
dashboardTitle: 'Dashboard Title',
|
||||||
|
items: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
dashboards: {
|
||||||
|
'Dashboard ID': {
|
||||||
|
dashboard: 'Dashboard ID',
|
||||||
|
dashboardTitle: 'Dashboard Title',
|
||||||
|
items: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
446
public/app/features/scopes/tests/utils/mocks.ts
Normal file
446
public/app/features/scopes/tests/utils/mocks.ts
Normal file
@ -0,0 +1,446 @@
|
|||||||
|
import { Scope, ScopeDashboardBinding, ScopeNode } from '@grafana/data';
|
||||||
|
|
||||||
|
import * as api from '../../internal/api';
|
||||||
|
|
||||||
|
export const mocksScopes: Scope[] = [
|
||||||
|
{
|
||||||
|
metadata: { name: 'cloud' },
|
||||||
|
spec: {
|
||||||
|
title: 'Cloud',
|
||||||
|
type: 'indexHelper',
|
||||||
|
description: 'redundant label filter but makes queries faster',
|
||||||
|
category: 'indexHelpers',
|
||||||
|
filters: [{ key: 'cloud', value: '.*', operator: 'regex-match' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metadata: { name: 'dev' },
|
||||||
|
spec: {
|
||||||
|
title: 'Dev',
|
||||||
|
type: 'cloud',
|
||||||
|
description: 'Dev',
|
||||||
|
category: 'cloud',
|
||||||
|
filters: [{ key: 'cloud', value: 'dev', operator: 'equals' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metadata: { name: 'ops' },
|
||||||
|
spec: {
|
||||||
|
title: 'Ops',
|
||||||
|
type: 'cloud',
|
||||||
|
description: 'Ops',
|
||||||
|
category: 'cloud',
|
||||||
|
filters: [{ key: 'cloud', value: 'ops', operator: 'equals' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metadata: { name: 'prod' },
|
||||||
|
spec: {
|
||||||
|
title: 'Prod',
|
||||||
|
type: 'cloud',
|
||||||
|
description: 'Prod',
|
||||||
|
category: 'cloud',
|
||||||
|
filters: [{ key: 'cloud', value: 'prod', operator: 'equals' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metadata: { name: 'grafana' },
|
||||||
|
spec: {
|
||||||
|
title: 'Grafana',
|
||||||
|
type: 'app',
|
||||||
|
description: 'Grafana',
|
||||||
|
category: 'apps',
|
||||||
|
filters: [{ key: 'app', value: 'grafana', operator: 'equals' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metadata: { name: 'mimir' },
|
||||||
|
spec: {
|
||||||
|
title: 'Mimir',
|
||||||
|
type: 'app',
|
||||||
|
description: 'Mimir',
|
||||||
|
category: 'apps',
|
||||||
|
filters: [{ key: 'app', value: 'mimir', operator: 'equals' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metadata: { name: 'loki' },
|
||||||
|
spec: {
|
||||||
|
title: 'Loki',
|
||||||
|
type: 'app',
|
||||||
|
description: 'Loki',
|
||||||
|
category: 'apps',
|
||||||
|
filters: [{ key: 'app', value: 'loki', operator: 'equals' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metadata: { name: 'tempo' },
|
||||||
|
spec: {
|
||||||
|
title: 'Tempo',
|
||||||
|
type: 'app',
|
||||||
|
description: 'Tempo',
|
||||||
|
category: 'apps',
|
||||||
|
filters: [{ key: 'app', value: 'tempo', operator: 'equals' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const dashboardBindingsGenerator = (
|
||||||
|
scopes: string[],
|
||||||
|
dashboards: Array<{ dashboardTitle: string; dashboardKey?: string; groups?: string[] }>
|
||||||
|
) =>
|
||||||
|
scopes.reduce<ScopeDashboardBinding[]>((scopeAcc, scopeTitle) => {
|
||||||
|
const scope = scopeTitle.toLowerCase().replaceAll(' ', '-').replaceAll('/', '-');
|
||||||
|
|
||||||
|
return [
|
||||||
|
...scopeAcc,
|
||||||
|
...dashboards.reduce<ScopeDashboardBinding[]>((acc, { dashboardTitle, groups, dashboardKey }, idx) => {
|
||||||
|
dashboardKey = dashboardKey ?? dashboardTitle.toLowerCase().replaceAll(' ', '-').replaceAll('/', '-');
|
||||||
|
const group = !groups
|
||||||
|
? ''
|
||||||
|
: groups.length === 1
|
||||||
|
? groups[0] === ''
|
||||||
|
? ''
|
||||||
|
: `${groups[0].toLowerCase().replaceAll(' ', '-').replaceAll('/', '-')}-`
|
||||||
|
: `multiple${idx}-`;
|
||||||
|
const dashboard = `${group}${dashboardKey}`;
|
||||||
|
|
||||||
|
return [
|
||||||
|
...acc,
|
||||||
|
{
|
||||||
|
metadata: { name: `${scope}-${dashboard}` },
|
||||||
|
spec: {
|
||||||
|
dashboard,
|
||||||
|
dashboardTitle,
|
||||||
|
scope,
|
||||||
|
groups,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, []),
|
||||||
|
];
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
export const mocksScopeDashboardBindings: ScopeDashboardBinding[] = [
|
||||||
|
...dashboardBindingsGenerator(
|
||||||
|
['Grafana'],
|
||||||
|
[
|
||||||
|
{ dashboardTitle: 'Data Sources', groups: ['General'] },
|
||||||
|
{ dashboardTitle: 'Usage', groups: ['General'] },
|
||||||
|
{ dashboardTitle: 'Frontend Errors', groups: ['Observability'] },
|
||||||
|
{ dashboardTitle: 'Frontend Logs', groups: ['Observability'] },
|
||||||
|
{ dashboardTitle: 'Backend Errors', groups: ['Observability'] },
|
||||||
|
{ dashboardTitle: 'Backend Logs', groups: ['Observability'] },
|
||||||
|
{ dashboardTitle: 'Usage Overview', groups: ['Usage'] },
|
||||||
|
{ dashboardTitle: 'Data Sources', groups: ['Usage'] },
|
||||||
|
{ dashboardTitle: 'Stats', groups: ['Usage'] },
|
||||||
|
{ dashboardTitle: 'Overview', groups: [''] },
|
||||||
|
{ dashboardTitle: 'Frontend' },
|
||||||
|
{ dashboardTitle: 'Stats' },
|
||||||
|
]
|
||||||
|
),
|
||||||
|
...dashboardBindingsGenerator(
|
||||||
|
['Loki', 'Tempo', 'Mimir'],
|
||||||
|
[
|
||||||
|
{ dashboardTitle: 'Ingester', groups: ['Components', 'Investigations'] },
|
||||||
|
{ dashboardTitle: 'Distributor', groups: ['Components', 'Investigations'] },
|
||||||
|
{ dashboardTitle: 'Compacter', groups: ['Components', 'Investigations'] },
|
||||||
|
{ dashboardTitle: 'Datasource Errors', groups: ['Observability', 'Investigations'] },
|
||||||
|
{ dashboardTitle: 'Datasource Logs', groups: ['Observability', 'Investigations'] },
|
||||||
|
{ dashboardTitle: 'Overview' },
|
||||||
|
{ dashboardTitle: 'Stats', dashboardKey: 'another-stats' },
|
||||||
|
]
|
||||||
|
),
|
||||||
|
...dashboardBindingsGenerator(
|
||||||
|
['Dev', 'Ops', 'Prod'],
|
||||||
|
[
|
||||||
|
{ dashboardTitle: 'Overview', groups: ['Cardinality Management'] },
|
||||||
|
{ dashboardTitle: 'Metrics', groups: ['Cardinality Management'] },
|
||||||
|
{ dashboardTitle: 'Labels', groups: ['Cardinality Management'] },
|
||||||
|
{ dashboardTitle: 'Overview', groups: ['Usage Insights'] },
|
||||||
|
{ dashboardTitle: 'Data Sources', groups: ['Usage Insights'] },
|
||||||
|
{ dashboardTitle: 'Query Errors', groups: ['Usage Insights'] },
|
||||||
|
{ dashboardTitle: 'Alertmanager', groups: ['Usage Insights'] },
|
||||||
|
{ dashboardTitle: 'Metrics Ingestion', groups: ['Usage Insights'] },
|
||||||
|
{ dashboardTitle: 'Billing/Usage' },
|
||||||
|
]
|
||||||
|
),
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const mocksNodes: Array<ScopeNode & { parent: string }> = [
|
||||||
|
{
|
||||||
|
parent: '',
|
||||||
|
metadata: { name: 'applications' },
|
||||||
|
spec: {
|
||||||
|
nodeType: 'container',
|
||||||
|
title: 'Applications',
|
||||||
|
description: 'Application Scopes',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
parent: '',
|
||||||
|
metadata: { name: 'cloud' },
|
||||||
|
spec: {
|
||||||
|
nodeType: 'container',
|
||||||
|
title: 'Cloud',
|
||||||
|
description: 'Cloud Scopes',
|
||||||
|
disableMultiSelect: true,
|
||||||
|
linkType: 'scope',
|
||||||
|
linkId: 'cloud',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
parent: 'applications',
|
||||||
|
metadata: { name: 'applications-grafana' },
|
||||||
|
spec: {
|
||||||
|
nodeType: 'leaf',
|
||||||
|
title: 'Grafana',
|
||||||
|
description: 'Grafana',
|
||||||
|
linkType: 'scope',
|
||||||
|
linkId: 'grafana',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
parent: 'applications',
|
||||||
|
metadata: { name: 'applications-mimir' },
|
||||||
|
spec: {
|
||||||
|
nodeType: 'leaf',
|
||||||
|
title: 'Mimir',
|
||||||
|
description: 'Mimir',
|
||||||
|
linkType: 'scope',
|
||||||
|
linkId: 'mimir',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
parent: 'applications',
|
||||||
|
metadata: { name: 'applications-loki' },
|
||||||
|
spec: {
|
||||||
|
nodeType: 'leaf',
|
||||||
|
title: 'Loki',
|
||||||
|
description: 'Loki',
|
||||||
|
linkType: 'scope',
|
||||||
|
linkId: 'loki',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
parent: 'applications',
|
||||||
|
metadata: { name: 'applications-tempo' },
|
||||||
|
spec: {
|
||||||
|
nodeType: 'leaf',
|
||||||
|
title: 'Tempo',
|
||||||
|
description: 'Tempo',
|
||||||
|
linkType: 'scope',
|
||||||
|
linkId: 'tempo',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
parent: 'applications',
|
||||||
|
metadata: { name: 'applications-cloud' },
|
||||||
|
spec: {
|
||||||
|
nodeType: 'container',
|
||||||
|
title: 'Cloud',
|
||||||
|
description: 'Application/Cloud Scopes',
|
||||||
|
linkType: 'scope',
|
||||||
|
linkId: 'cloud',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
parent: 'applications-cloud',
|
||||||
|
metadata: { name: 'applications-cloud-dev' },
|
||||||
|
spec: {
|
||||||
|
nodeType: 'leaf',
|
||||||
|
title: 'Dev',
|
||||||
|
description: 'Dev',
|
||||||
|
linkType: 'scope',
|
||||||
|
linkId: 'dev',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
parent: 'applications-cloud',
|
||||||
|
metadata: { name: 'applications-cloud-ops' },
|
||||||
|
spec: {
|
||||||
|
nodeType: 'leaf',
|
||||||
|
title: 'Ops',
|
||||||
|
description: 'Ops',
|
||||||
|
linkType: 'scope',
|
||||||
|
linkId: 'ops',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
parent: 'applications-cloud',
|
||||||
|
metadata: { name: 'applications-cloud-prod' },
|
||||||
|
spec: {
|
||||||
|
nodeType: 'leaf',
|
||||||
|
title: 'Prod',
|
||||||
|
description: 'Prod',
|
||||||
|
linkType: 'scope',
|
||||||
|
linkId: 'prod',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
parent: 'cloud',
|
||||||
|
metadata: { name: 'cloud-dev' },
|
||||||
|
spec: {
|
||||||
|
nodeType: 'leaf',
|
||||||
|
title: 'Dev',
|
||||||
|
description: 'Dev',
|
||||||
|
linkType: 'scope',
|
||||||
|
linkId: 'dev',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
parent: 'cloud',
|
||||||
|
metadata: { name: 'cloud-ops' },
|
||||||
|
spec: {
|
||||||
|
nodeType: 'leaf',
|
||||||
|
title: 'Ops',
|
||||||
|
description: 'Ops',
|
||||||
|
linkType: 'scope',
|
||||||
|
linkId: 'ops',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
parent: 'cloud',
|
||||||
|
metadata: { name: 'cloud-prod' },
|
||||||
|
spec: {
|
||||||
|
nodeType: 'leaf',
|
||||||
|
title: 'Prod',
|
||||||
|
description: 'Prod',
|
||||||
|
linkType: 'scope',
|
||||||
|
linkId: 'prod',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
parent: 'cloud',
|
||||||
|
metadata: { name: 'cloud-applications' },
|
||||||
|
spec: {
|
||||||
|
nodeType: 'container',
|
||||||
|
title: 'Applications',
|
||||||
|
description: 'Cloud/Application Scopes',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
parent: 'cloud-applications',
|
||||||
|
metadata: { name: 'cloud-applications-grafana' },
|
||||||
|
spec: {
|
||||||
|
nodeType: 'leaf',
|
||||||
|
title: 'Grafana',
|
||||||
|
description: 'Grafana',
|
||||||
|
linkType: 'scope',
|
||||||
|
linkId: 'grafana',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
parent: 'cloud-applications',
|
||||||
|
metadata: { name: 'cloud-applications-mimir' },
|
||||||
|
spec: {
|
||||||
|
nodeType: 'leaf',
|
||||||
|
title: 'Mimir',
|
||||||
|
description: 'Mimir',
|
||||||
|
linkType: 'scope',
|
||||||
|
linkId: 'mimir',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
parent: 'cloud-applications',
|
||||||
|
metadata: { name: 'cloud-applications-loki' },
|
||||||
|
spec: {
|
||||||
|
nodeType: 'leaf',
|
||||||
|
title: 'Loki',
|
||||||
|
description: 'Loki',
|
||||||
|
linkType: 'scope',
|
||||||
|
linkId: 'loki',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
parent: 'cloud-applications',
|
||||||
|
metadata: { name: 'cloud-applications-tempo' },
|
||||||
|
spec: {
|
||||||
|
nodeType: 'leaf',
|
||||||
|
title: 'Tempo',
|
||||||
|
description: 'Tempo',
|
||||||
|
linkType: 'scope',
|
||||||
|
linkId: 'tempo',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const fetchNodesSpy = jest.spyOn(api, 'fetchNodes');
|
||||||
|
export const fetchScopeSpy = jest.spyOn(api, 'fetchScope');
|
||||||
|
export const fetchSelectedScopesSpy = jest.spyOn(api, 'fetchSelectedScopes');
|
||||||
|
export const fetchDashboardsSpy = jest.spyOn(api, 'fetchDashboards');
|
||||||
|
|
||||||
|
export const getMock = jest
|
||||||
|
.fn()
|
||||||
|
.mockImplementation((url: string, params: { parent: string; scope: string[]; query?: string }) => {
|
||||||
|
if (url.startsWith('/apis/scope.grafana.app/v0alpha1/namespaces/default/find/scope_node_children')) {
|
||||||
|
return {
|
||||||
|
items: mocksNodes.filter(
|
||||||
|
({ parent, spec: { title } }) =>
|
||||||
|
parent === params.parent && title.toLowerCase().includes((params.query ?? '').toLowerCase())
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.startsWith('/apis/scope.grafana.app/v0alpha1/namespaces/default/scopes/')) {
|
||||||
|
const name = url.replace('/apis/scope.grafana.app/v0alpha1/namespaces/default/scopes/', '');
|
||||||
|
|
||||||
|
return mocksScopes.find((scope) => scope.metadata.name.toLowerCase() === name.toLowerCase()) ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.startsWith('/apis/scope.grafana.app/v0alpha1/namespaces/default/find/scope_dashboard_bindings')) {
|
||||||
|
return {
|
||||||
|
items: mocksScopeDashboardBindings.filter(({ spec: { scope: bindingScope } }) =>
|
||||||
|
params.scope.includes(bindingScope)
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.startsWith('/api/dashboards/uid/')) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.startsWith('/apis/dashboard.grafana.app/v0alpha1/namespaces/default/dashboards/')) {
|
||||||
|
return {
|
||||||
|
metadata: {
|
||||||
|
name: '1',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
|
||||||
|
const generateScopeDashboardBinding = (dashboardTitle: string, groups?: string[], dashboardId?: string) => ({
|
||||||
|
metadata: { name: `${dashboardTitle}-name` },
|
||||||
|
spec: {
|
||||||
|
dashboard: `${dashboardId ?? dashboardTitle}-dashboard`,
|
||||||
|
dashboardTitle,
|
||||||
|
scope: `${dashboardTitle}-scope`,
|
||||||
|
groups,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const dashboardWithoutFolder: ScopeDashboardBinding = generateScopeDashboardBinding('Without Folder');
|
||||||
|
export const dashboardWithOneFolder: ScopeDashboardBinding = generateScopeDashboardBinding('With one folder', [
|
||||||
|
'Folder 1',
|
||||||
|
]);
|
||||||
|
export const dashboardWithTwoFolders: ScopeDashboardBinding = generateScopeDashboardBinding('With two folders', [
|
||||||
|
'Folder 1',
|
||||||
|
'Folder 2',
|
||||||
|
]);
|
||||||
|
export const alternativeDashboardWithTwoFolders: ScopeDashboardBinding = generateScopeDashboardBinding(
|
||||||
|
'Alternative with two folders',
|
||||||
|
['Folder 1', 'Folder 2'],
|
||||||
|
'With two folders'
|
||||||
|
);
|
||||||
|
export const dashboardWithRootFolder: ScopeDashboardBinding = generateScopeDashboardBinding('With root folder', ['']);
|
||||||
|
export const alternativeDashboardWithRootFolder: ScopeDashboardBinding = generateScopeDashboardBinding(
|
||||||
|
'Alternative With root folder',
|
||||||
|
[''],
|
||||||
|
'With root folder'
|
||||||
|
);
|
||||||
|
export const dashboardWithRootFolderAndOtherFolder: ScopeDashboardBinding = generateScopeDashboardBinding(
|
||||||
|
'With root folder and other folder',
|
||||||
|
['', 'Folder 3']
|
||||||
|
);
|
92
public/app/features/scopes/tests/utils/render.tsx
Normal file
92
public/app/features/scopes/tests/utils/render.tsx
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import { KBarProvider } from 'kbar';
|
||||||
|
import { render } from 'test/test-utils';
|
||||||
|
|
||||||
|
import {
|
||||||
|
AdHocFiltersVariable,
|
||||||
|
behaviors,
|
||||||
|
GroupByVariable,
|
||||||
|
sceneGraph,
|
||||||
|
SceneGridItem,
|
||||||
|
SceneGridLayout,
|
||||||
|
SceneQueryRunner,
|
||||||
|
SceneTimeRange,
|
||||||
|
SceneVariableSet,
|
||||||
|
VizPanel,
|
||||||
|
} from '@grafana/scenes';
|
||||||
|
import { AppChrome } from 'app/core/components/AppChrome/AppChrome';
|
||||||
|
import { DashboardControls } from 'app/features/dashboard-scene/scene//DashboardControls';
|
||||||
|
import { DashboardScene } from 'app/features/dashboard-scene/scene/DashboardScene';
|
||||||
|
|
||||||
|
import { ScopesFacade } from '../../ScopesFacadeScene';
|
||||||
|
import { scopesDashboardsScene, scopesSelectorScene } from '../../instance';
|
||||||
|
import { getInitialDashboardsState } from '../../internal/ScopesDashboardsScene';
|
||||||
|
import { initialSelectorState } from '../../internal/ScopesSelectorScene';
|
||||||
|
import { DASHBOARDS_OPENED_KEY } from '../../internal/const';
|
||||||
|
|
||||||
|
export function buildTestScene(overrides: Partial<DashboardScene> = {}) {
|
||||||
|
return new DashboardScene({
|
||||||
|
title: 'hello',
|
||||||
|
uid: 'dash-1',
|
||||||
|
description: 'hello description',
|
||||||
|
tags: ['tag1', 'tag2'],
|
||||||
|
editable: true,
|
||||||
|
$timeRange: new SceneTimeRange({
|
||||||
|
timeZone: 'browser',
|
||||||
|
}),
|
||||||
|
controls: new DashboardControls({}),
|
||||||
|
$behaviors: [
|
||||||
|
new behaviors.CursorSync({}),
|
||||||
|
new ScopesFacade({
|
||||||
|
handler: (facade) => sceneGraph.getTimeRange(facade).onRefresh(),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
$variables: new SceneVariableSet({
|
||||||
|
variables: [
|
||||||
|
new AdHocFiltersVariable({
|
||||||
|
name: 'adhoc',
|
||||||
|
datasource: { uid: 'my-ds-uid' },
|
||||||
|
}),
|
||||||
|
new GroupByVariable({
|
||||||
|
name: 'groupby',
|
||||||
|
datasource: { uid: 'my-ds-uid' },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
body: new SceneGridLayout({
|
||||||
|
children: [
|
||||||
|
new SceneGridItem({
|
||||||
|
key: 'griditem-1',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 300,
|
||||||
|
height: 300,
|
||||||
|
body: new VizPanel({
|
||||||
|
title: 'Panel A',
|
||||||
|
key: 'panel-1',
|
||||||
|
pluginId: 'table',
|
||||||
|
$data: new SceneQueryRunner({ key: 'data-query-runner', queries: [{ refId: 'A' }] }),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderDashboard(dashboardScene: DashboardScene) {
|
||||||
|
return render(
|
||||||
|
<KBarProvider>
|
||||||
|
<AppChrome>
|
||||||
|
<dashboardScene.Component model={dashboardScene} />
|
||||||
|
</AppChrome>
|
||||||
|
</KBarProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetScenes() {
|
||||||
|
scopesSelectorScene?.setState(initialSelectorState);
|
||||||
|
|
||||||
|
localStorage.removeItem(DASHBOARDS_OPENED_KEY);
|
||||||
|
|
||||||
|
scopesDashboardsScene?.setState(getInitialDashboardsState());
|
||||||
|
}
|
92
public/app/features/scopes/tests/utils/selectors.ts
Normal file
92
public/app/features/scopes/tests/utils/selectors.ts
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import { screen } from '@testing-library/react';
|
||||||
|
|
||||||
|
const selectors = {
|
||||||
|
tree: {
|
||||||
|
search: 'scopes-tree-search',
|
||||||
|
headline: 'scopes-tree-headline',
|
||||||
|
select: (nodeId: string, type: 'result' | 'persisted') => `scopes-tree-${type}-${nodeId}-checkbox`,
|
||||||
|
radio: (nodeId: string, type: 'result' | 'persisted') => `scopes-tree-${type}-${nodeId}-radio`,
|
||||||
|
expand: (nodeId: string, type: 'result' | 'persisted') => `scopes-tree-${type}-${nodeId}-expand`,
|
||||||
|
title: (nodeId: string, type: 'result' | 'persisted') => `scopes-tree-${type}-${nodeId}-title`,
|
||||||
|
},
|
||||||
|
selector: {
|
||||||
|
input: 'scopes-selector-input',
|
||||||
|
container: 'scopes-selector-container',
|
||||||
|
loading: 'scopes-selector-loading',
|
||||||
|
apply: 'scopes-selector-apply',
|
||||||
|
cancel: 'scopes-selector-cancel',
|
||||||
|
},
|
||||||
|
dashboards: {
|
||||||
|
expand: 'scopes-dashboards-expand',
|
||||||
|
container: 'scopes-dashboards-container',
|
||||||
|
search: 'scopes-dashboards-search',
|
||||||
|
loading: 'scopes-dashboards-loading',
|
||||||
|
dashboard: (uid: string) => `scopes-dashboards-${uid}`,
|
||||||
|
dashboardExpand: (uid: string) => `scopes-dashboards-${uid}-expand`,
|
||||||
|
notFoundNoScopes: 'scopes-dashboards-notFoundNoScopes',
|
||||||
|
notFoundForScope: 'scopes-dashboards-notFoundForScope',
|
||||||
|
notFoundForFilter: 'scopes-dashboards-notFoundForFilter',
|
||||||
|
notFoundForFilterClear: 'scopes-dashboards-notFoundForFilter-clear',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getSelectorInput = () => screen.getByTestId<HTMLInputElement>(selectors.selector.input);
|
||||||
|
export const querySelectorApply = () => screen.queryByTestId(selectors.selector.apply);
|
||||||
|
export const getSelectorApply = () => screen.getByTestId(selectors.selector.apply);
|
||||||
|
export const getSelectorCancel = () => screen.getByTestId(selectors.selector.cancel);
|
||||||
|
|
||||||
|
export const getDashboardsExpand = () => screen.getByTestId(selectors.dashboards.expand);
|
||||||
|
export const queryDashboardsContainer = () => screen.queryByTestId(selectors.dashboards.container);
|
||||||
|
export const queryDashboardsSearch = () => screen.queryByTestId(selectors.dashboards.search);
|
||||||
|
export const getDashboardsSearch = () => screen.getByTestId<HTMLInputElement>(selectors.dashboards.search);
|
||||||
|
export const queryDashboardFolderExpand = (uid: string) =>
|
||||||
|
screen.queryByTestId(selectors.dashboards.dashboardExpand(uid));
|
||||||
|
export const getDashboardFolderExpand = (uid: string) => screen.getByTestId(selectors.dashboards.dashboardExpand(uid));
|
||||||
|
export const queryAllDashboard = (uid: string) => screen.queryAllByTestId(selectors.dashboards.dashboard(uid));
|
||||||
|
export const queryDashboard = (uid: string) => screen.queryByTestId(selectors.dashboards.dashboard(uid));
|
||||||
|
export const getDashboard = (uid: string) => screen.getByTestId(selectors.dashboards.dashboard(uid));
|
||||||
|
export const getNotFoundNoScopes = () => screen.getByTestId(selectors.dashboards.notFoundNoScopes);
|
||||||
|
export const getNotFoundForScope = () => screen.getByTestId(selectors.dashboards.notFoundForScope);
|
||||||
|
export const getNotFoundForFilter = () => screen.getByTestId(selectors.dashboards.notFoundForFilter);
|
||||||
|
export const getNotFoundForFilterClear = () => screen.getByTestId(selectors.dashboards.notFoundForFilterClear);
|
||||||
|
|
||||||
|
export const getTreeSearch = () => screen.getByTestId<HTMLInputElement>(selectors.tree.search);
|
||||||
|
export const getTreeHeadline = () => screen.getByTestId(selectors.tree.headline);
|
||||||
|
export const getResultApplicationsExpand = () => screen.getByTestId(selectors.tree.expand('applications', 'result'));
|
||||||
|
export const queryResultApplicationsGrafanaTitle = () =>
|
||||||
|
screen.queryByTestId(selectors.tree.title('applications-grafana', 'result'));
|
||||||
|
export const getResultApplicationsGrafanaTitle = () =>
|
||||||
|
screen.getByTestId(selectors.tree.title('applications-grafana', 'result'));
|
||||||
|
export const getResultApplicationsGrafanaSelect = () =>
|
||||||
|
screen.getByTestId(selectors.tree.select('applications-grafana', 'result'));
|
||||||
|
export const queryPersistedApplicationsGrafanaTitle = () =>
|
||||||
|
screen.queryByTestId(selectors.tree.title('applications-grafana', 'persisted'));
|
||||||
|
export const queryResultApplicationsMimirTitle = () =>
|
||||||
|
screen.queryByTestId(selectors.tree.title('applications-mimir', 'result'));
|
||||||
|
export const getResultApplicationsMimirTitle = () =>
|
||||||
|
screen.getByTestId(selectors.tree.title('applications-mimir', 'result'));
|
||||||
|
export const getResultApplicationsMimirSelect = () =>
|
||||||
|
screen.getByTestId(selectors.tree.select('applications-mimir', 'result'));
|
||||||
|
export const queryPersistedApplicationsMimirTitle = () =>
|
||||||
|
screen.queryByTestId(selectors.tree.title('applications-mimir', 'persisted'));
|
||||||
|
export const getPersistedApplicationsMimirTitle = () =>
|
||||||
|
screen.getByTestId(selectors.tree.title('applications-mimir', 'persisted'));
|
||||||
|
export const getPersistedApplicationsMimirSelect = () =>
|
||||||
|
screen.getByTestId(selectors.tree.select('applications-mimir', 'persisted'));
|
||||||
|
export const queryResultApplicationsCloudTitle = () =>
|
||||||
|
screen.queryByTestId(selectors.tree.title('applications-cloud', 'result'));
|
||||||
|
export const getResultApplicationsCloudSelect = () =>
|
||||||
|
screen.getByTestId(selectors.tree.select('applications-cloud', 'result'));
|
||||||
|
export const getResultApplicationsCloudExpand = () =>
|
||||||
|
screen.getByTestId(selectors.tree.expand('applications-cloud', 'result'));
|
||||||
|
export const getResultApplicationsCloudDevSelect = () =>
|
||||||
|
screen.getByTestId(selectors.tree.select('applications-cloud-dev', 'result'));
|
||||||
|
export const getResultApplicationsCloudOpsSelect = () =>
|
||||||
|
screen.getByTestId(selectors.tree.select('applications-cloud-ops', 'result'));
|
||||||
|
|
||||||
|
export const getResultCloudSelect = () => screen.getByTestId(selectors.tree.select('cloud', 'result'));
|
||||||
|
export const getResultCloudExpand = () => screen.getByTestId(selectors.tree.expand('cloud', 'result'));
|
||||||
|
export const getResultCloudDevRadio = () =>
|
||||||
|
screen.getByTestId<HTMLInputElement>(selectors.tree.radio('cloud-dev', 'result'));
|
||||||
|
export const getResultCloudOpsRadio = () =>
|
||||||
|
screen.getByTestId<HTMLInputElement>(selectors.tree.radio('cloud-ops', 'result'));
|
@ -2129,6 +2129,8 @@
|
|||||||
},
|
},
|
||||||
"scopes": {
|
"scopes": {
|
||||||
"dashboards": {
|
"dashboards": {
|
||||||
|
"collapse": "Collapse",
|
||||||
|
"expand": "Expand",
|
||||||
"loading": "Loading dashboards",
|
"loading": "Loading dashboards",
|
||||||
"noResultsForFilter": "No results found for your query",
|
"noResultsForFilter": "No results found for your query",
|
||||||
"noResultsForFilterClear": "Clear search",
|
"noResultsForFilterClear": "Clear search",
|
||||||
|
@ -2129,6 +2129,8 @@
|
|||||||
},
|
},
|
||||||
"scopes": {
|
"scopes": {
|
||||||
"dashboards": {
|
"dashboards": {
|
||||||
|
"collapse": "Cőľľäpşę",
|
||||||
|
"expand": "Ēχpäʼnđ",
|
||||||
"loading": "Ŀőäđįʼnģ đäşĥþőäřđş",
|
"loading": "Ŀőäđįʼnģ đäşĥþőäřđş",
|
||||||
"noResultsForFilter": "Ńő řęşūľŧş ƒőūʼnđ ƒőř yőūř qūęřy",
|
"noResultsForFilter": "Ńő řęşūľŧş ƒőūʼnđ ƒőř yőūř qūęřy",
|
||||||
"noResultsForFilterClear": "Cľęäř şęäřčĥ",
|
"noResultsForFilterClear": "Cľęäř şęäřčĥ",
|
||||||
|
Loading…
Reference in New Issue
Block a user