mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Scopes: UI Improvements (#88026)
This commit is contained in:
parent
d88f2734ae
commit
8d36949f61
@ -1,8 +1,17 @@
|
|||||||
export interface ScopeDashboardBindingSpec {
|
export interface ScopeDashboardBindingSpec {
|
||||||
dashboard: string;
|
dashboard: string;
|
||||||
|
dashboardTitle: string;
|
||||||
scope: string;
|
scope: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: Use Resource from apiserver when we export the types
|
||||||
|
export interface ScopeDashboardBinding {
|
||||||
|
metadata: {
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
spec: ScopeDashboardBindingSpec;
|
||||||
|
}
|
||||||
|
|
||||||
export type ScopeFilterOperator = 'equals' | 'not-equals' | 'regex-match' | 'regex-not-match';
|
export type ScopeFilterOperator = 'equals' | 'not-equals' | 'regex-match' | 'regex-not-match';
|
||||||
|
|
||||||
export const scopeFilterOperatorMap: Record<string, ScopeFilterOperator> = {
|
export const scopeFilterOperatorMap: Record<string, ScopeFilterOperator> = {
|
||||||
@ -34,15 +43,23 @@ export interface Scope {
|
|||||||
spec: ScopeSpec;
|
spec: ScopeSpec;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ScopeTreeItemNodeType = 'container' | 'leaf';
|
export type ScopeNodeNodeType = 'container' | 'leaf';
|
||||||
export type ScopeTreeItemLinkType = 'scope';
|
export type ScopeNodeLinkType = 'scope';
|
||||||
|
|
||||||
export interface ScopeTreeItemSpec {
|
export interface ScopeNodeSpec {
|
||||||
nodeId: string;
|
nodeType: ScopeNodeNodeType;
|
||||||
nodeType: ScopeTreeItemNodeType;
|
|
||||||
title: string;
|
title: string;
|
||||||
|
|
||||||
description?: string;
|
description?: string;
|
||||||
|
disableMultiSelect?: boolean;
|
||||||
linkId?: string;
|
linkId?: string;
|
||||||
linkType?: ScopeTreeItemLinkType;
|
linkType?: ScopeNodeLinkType;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Use Resource from apiserver when we export the types
|
||||||
|
export interface ScopeNode {
|
||||||
|
metadata: {
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
spec: ScopeNodeSpec;
|
||||||
}
|
}
|
||||||
|
@ -66,7 +66,7 @@ import { DashboardSceneRenderer } from './DashboardSceneRenderer';
|
|||||||
import { DashboardSceneUrlSync } from './DashboardSceneUrlSync';
|
import { DashboardSceneUrlSync } from './DashboardSceneUrlSync';
|
||||||
import { LibraryVizPanel } from './LibraryVizPanel';
|
import { LibraryVizPanel } from './LibraryVizPanel';
|
||||||
import { RowRepeaterBehavior } from './RowRepeaterBehavior';
|
import { RowRepeaterBehavior } from './RowRepeaterBehavior';
|
||||||
import { ScopesScene } from './ScopesScene';
|
import { ScopesScene } from './Scopes/ScopesScene';
|
||||||
import { ViewPanelScene } from './ViewPanelScene';
|
import { ViewPanelScene } from './ViewPanelScene';
|
||||||
import { setupKeyboardShortcuts } from './keyboardShortcuts';
|
import { setupKeyboardShortcuts } from './keyboardShortcuts';
|
||||||
|
|
||||||
|
@ -0,0 +1,125 @@
|
|||||||
|
import { css } from '@emotion/css';
|
||||||
|
import React from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { GrafanaTheme2, Scope, ScopeDashboardBinding, urlUtil } from '@grafana/data';
|
||||||
|
import { SceneComponentProps, SceneObjectBase, SceneObjectState } from '@grafana/scenes';
|
||||||
|
import { CustomScrollbar, Icon, Input, LoadingPlaceholder, useStyles2 } from '@grafana/ui';
|
||||||
|
import { useQueryParams } from 'app/core/hooks/useQueryParams';
|
||||||
|
import { t } from 'app/core/internationalization';
|
||||||
|
|
||||||
|
import { fetchDashboards } from './api';
|
||||||
|
|
||||||
|
export interface ScopesDashboardsSceneState extends SceneObjectState {
|
||||||
|
dashboards: ScopeDashboardBinding[];
|
||||||
|
filteredDashboards: ScopeDashboardBinding[];
|
||||||
|
isLoading: boolean;
|
||||||
|
searchQuery: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ScopesDashboardsScene extends SceneObjectBase<ScopesDashboardsSceneState> {
|
||||||
|
static Component = ScopesDashboardsSceneRenderer;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super({
|
||||||
|
dashboards: [],
|
||||||
|
filteredDashboards: [],
|
||||||
|
isLoading: false,
|
||||||
|
searchQuery: '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async fetchDashboards(scopes: Scope[]) {
|
||||||
|
if (scopes.length === 0) {
|
||||||
|
return this.setState({ dashboards: [], filteredDashboards: [], isLoading: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setState({ isLoading: true });
|
||||||
|
|
||||||
|
const dashboards = await fetchDashboards(scopes);
|
||||||
|
|
||||||
|
this.setState({
|
||||||
|
dashboards,
|
||||||
|
filteredDashboards: this.filterDashboards(dashboards, this.state.searchQuery),
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public changeSearchQuery(searchQuery: string) {
|
||||||
|
this.setState({
|
||||||
|
filteredDashboards: searchQuery
|
||||||
|
? this.filterDashboards(this.state.dashboards, searchQuery)
|
||||||
|
: this.state.dashboards,
|
||||||
|
searchQuery: searchQuery ?? '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private filterDashboards(dashboards: ScopeDashboardBinding[], searchQuery: string) {
|
||||||
|
const lowerCasedSearchQuery = searchQuery.toLowerCase();
|
||||||
|
|
||||||
|
return dashboards.filter(({ spec: { dashboardTitle } }) =>
|
||||||
|
dashboardTitle.toLowerCase().includes(lowerCasedSearchQuery)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScopesDashboardsSceneRenderer({ model }: SceneComponentProps<ScopesDashboardsScene>) {
|
||||||
|
const { filteredDashboards, isLoading } = model.useState();
|
||||||
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
|
const [queryParams] = useQueryParams();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={styles.searchInputContainer}>
|
||||||
|
<Input
|
||||||
|
prefix={<Icon name="search" />}
|
||||||
|
placeholder={t('scopes.suggestedDashboards.search', 'Filter')}
|
||||||
|
disabled={isLoading}
|
||||||
|
data-testid="scopes-dashboards-search"
|
||||||
|
onChange={(evt) => model.changeSearchQuery(evt.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<LoadingPlaceholder
|
||||||
|
className={styles.loadingIndicator}
|
||||||
|
text={t('scopes.suggestedDashboards.loading', 'Loading dashboards')}
|
||||||
|
data-testid="scopes-dashboards-loading"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<CustomScrollbar>
|
||||||
|
{filteredDashboards.map(({ spec: { dashboard, dashboardTitle } }) => (
|
||||||
|
<Link
|
||||||
|
key={dashboard}
|
||||||
|
to={urlUtil.renderUrl(`/d/${dashboard}/`, queryParams)}
|
||||||
|
className={styles.dashboardItem}
|
||||||
|
data-testid={`scopes-dashboards-${dashboard}`}
|
||||||
|
>
|
||||||
|
{dashboardTitle}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</CustomScrollbar>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStyles = (theme: GrafanaTheme2) => {
|
||||||
|
return {
|
||||||
|
searchInputContainer: css({
|
||||||
|
flex: '0 1 auto',
|
||||||
|
}),
|
||||||
|
loadingIndicator: css({
|
||||||
|
alignSelf: 'center',
|
||||||
|
}),
|
||||||
|
dashboardItem: css({
|
||||||
|
padding: theme.spacing(1, 0),
|
||||||
|
borderBottom: `1px solid ${theme.colors.border.weak}`,
|
||||||
|
|
||||||
|
'& :is(:first-child)': {
|
||||||
|
paddingTop: 0,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
};
|
@ -0,0 +1,76 @@
|
|||||||
|
import { css } from '@emotion/css';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
|
import { SceneComponentProps } from '@grafana/scenes';
|
||||||
|
import { Button, Drawer, Spinner, useStyles2 } from '@grafana/ui';
|
||||||
|
import { t, Trans } from 'app/core/internationalization';
|
||||||
|
|
||||||
|
import { ScopesFiltersScene } from './ScopesFiltersScene';
|
||||||
|
import { ScopesTreeLevel } from './ScopesTreeLevel';
|
||||||
|
|
||||||
|
export function ScopesFiltersAdvancedSelector({ model }: SceneComponentProps<ScopesFiltersScene>) {
|
||||||
|
const styles = useStyles2(getStyles);
|
||||||
|
const { nodes, loadingNodeName, dirtyScopeNames, isLoadingScopes, isAdvancedOpened } = model.useState();
|
||||||
|
|
||||||
|
if (!isAdvancedOpened) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
title={t('scopes.advancedSelector.title', 'Select scopes')}
|
||||||
|
size="sm"
|
||||||
|
onClose={() => {
|
||||||
|
model.closeAdvancedSelector();
|
||||||
|
model.resetDirtyScopeNames();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isLoadingScopes ? (
|
||||||
|
<Spinner data-testid="scopes-advanced-loading" />
|
||||||
|
) : (
|
||||||
|
<ScopesTreeLevel
|
||||||
|
showQuery={true}
|
||||||
|
nodes={nodes}
|
||||||
|
nodePath={['']}
|
||||||
|
loadingNodeName={loadingNodeName}
|
||||||
|
scopeNames={dirtyScopeNames}
|
||||||
|
onNodeUpdate={(path, isExpanded, query) => model.updateNode(path, isExpanded, query)}
|
||||||
|
onNodeSelectToggle={(path) => model.toggleNodeSelect(path)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className={styles.buttonGroup}>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
data-testid="scopes-advanced-apply"
|
||||||
|
onClick={() => {
|
||||||
|
model.closeAdvancedSelector();
|
||||||
|
model.updateScopes();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trans i18nKey="scopes.advancedSelector.apply">Apply</Trans>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
data-testid="scopes-advanced-cancel"
|
||||||
|
onClick={() => {
|
||||||
|
model.closeAdvancedSelector();
|
||||||
|
model.resetDirtyScopeNames();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trans i18nKey="scopes.advancedSelector.cancel">Cancel</Trans>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStyles = (theme: GrafanaTheme2) => {
|
||||||
|
return {
|
||||||
|
buttonGroup: css({
|
||||||
|
display: 'flex',
|
||||||
|
gap: theme.spacing(1),
|
||||||
|
marginTop: theme.spacing(8),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
};
|
@ -0,0 +1,110 @@
|
|||||||
|
import { css } from '@emotion/css';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
|
import { SceneComponentProps } from '@grafana/scenes';
|
||||||
|
import { Icon, IconButton, Input, Spinner, Toggletip, useStyles2 } from '@grafana/ui';
|
||||||
|
import { t, Trans } from 'app/core/internationalization';
|
||||||
|
|
||||||
|
import { ScopesFiltersScene } from './ScopesFiltersScene';
|
||||||
|
import { ScopesTreeLevel } from './ScopesTreeLevel';
|
||||||
|
|
||||||
|
export function ScopesFiltersBasicSelector({ model }: SceneComponentProps<ScopesFiltersScene>) {
|
||||||
|
const styles = useStyles2(getStyles);
|
||||||
|
const { nodes, loadingNodeName, scopes, dirtyScopeNames, isLoadingScopes, isBasicOpened } = model.useState();
|
||||||
|
const { isViewing } = model.scopesParent.useState();
|
||||||
|
|
||||||
|
const scopesTitles = scopes.map(({ spec: { title } }) => title).join(', ');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container} data-testid="scopes-basic-container">
|
||||||
|
<Toggletip
|
||||||
|
show={isBasicOpened}
|
||||||
|
closeButton={false}
|
||||||
|
content={
|
||||||
|
<div className={styles.innerContainer} data-testid="scopes-basic-inner-container">
|
||||||
|
{isLoadingScopes ? (
|
||||||
|
<Spinner data-testid="scopes-basic-loading" />
|
||||||
|
) : (
|
||||||
|
<ScopesTreeLevel
|
||||||
|
showQuery={false}
|
||||||
|
nodes={nodes}
|
||||||
|
nodePath={['']}
|
||||||
|
loadingNodeName={loadingNodeName}
|
||||||
|
scopeNames={dirtyScopeNames}
|
||||||
|
onNodeUpdate={(path, isExpanded, query) => model.updateNode(path, isExpanded, query)}
|
||||||
|
onNodeSelectToggle={(path) => model.toggleNodeSelect(path)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
footer={
|
||||||
|
<button
|
||||||
|
className={styles.openAdvancedButton}
|
||||||
|
data-testid="scopes-basic-open-advanced"
|
||||||
|
onClick={() => model.openAdvancedSelector()}
|
||||||
|
>
|
||||||
|
<Trans i18nKey="scopes.basicSelector.openAdvanced">
|
||||||
|
Open advanced scope selector <Icon name="arrow-right" />
|
||||||
|
</Trans>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
onOpen={() => model.openBasicSelector()}
|
||||||
|
onClose={() => {
|
||||||
|
model.closeBasicSelector();
|
||||||
|
model.updateScopes();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
readOnly
|
||||||
|
placeholder={t('scopes.basicSelector.placeholder', 'Select scopes...')}
|
||||||
|
loading={isLoadingScopes}
|
||||||
|
value={scopesTitles}
|
||||||
|
aria-label={t('scopes.basicSelector.placeholder', 'Select scopes...')}
|
||||||
|
data-testid="scopes-basic-input"
|
||||||
|
suffix={
|
||||||
|
scopes.length > 0 && !isViewing ? (
|
||||||
|
<IconButton
|
||||||
|
aria-label={t('scopes.basicSelector.removeAll', 'Remove all scopes')}
|
||||||
|
name="times"
|
||||||
|
onClick={() => model.removeAllScopes()}
|
||||||
|
/>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Toggletip>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStyles = (theme: GrafanaTheme2) => {
|
||||||
|
return {
|
||||||
|
container: css({
|
||||||
|
width: '100%',
|
||||||
|
|
||||||
|
'& > div': css({
|
||||||
|
padding: 0,
|
||||||
|
|
||||||
|
'& > div': css({
|
||||||
|
padding: 0,
|
||||||
|
margin: 0,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
innerContainer: css({
|
||||||
|
minWidth: 400,
|
||||||
|
padding: theme.spacing(0, 1),
|
||||||
|
}),
|
||||||
|
openAdvancedButton: css({
|
||||||
|
backgroundColor: theme.colors.secondary.main,
|
||||||
|
border: 'none',
|
||||||
|
borderTop: `1px solid ${theme.colors.secondary.border}`,
|
||||||
|
display: 'block',
|
||||||
|
fontSize: theme.typography.pxToRem(12),
|
||||||
|
margin: 0,
|
||||||
|
padding: theme.spacing(1.5),
|
||||||
|
textAlign: 'right',
|
||||||
|
width: '100%',
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
};
|
@ -0,0 +1,191 @@
|
|||||||
|
import { isEqual } from 'lodash';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { Scope } from '@grafana/data';
|
||||||
|
import {
|
||||||
|
SceneComponentProps,
|
||||||
|
sceneGraph,
|
||||||
|
SceneObjectBase,
|
||||||
|
SceneObjectState,
|
||||||
|
SceneObjectUrlSyncConfig,
|
||||||
|
SceneObjectUrlValues,
|
||||||
|
SceneObjectWithUrlSync,
|
||||||
|
} from '@grafana/scenes';
|
||||||
|
|
||||||
|
import { ScopesFiltersAdvancedSelector } from './ScopesFiltersAdvancedSelector';
|
||||||
|
import { ScopesFiltersBasicSelector } from './ScopesFiltersBasicSelector';
|
||||||
|
import { ScopesScene } from './ScopesScene';
|
||||||
|
import { fetchNodes, fetchScope, fetchScopes } from './api';
|
||||||
|
import { NodesMap } from './types';
|
||||||
|
|
||||||
|
export interface ScopesFiltersSceneState extends SceneObjectState {
|
||||||
|
nodes: NodesMap;
|
||||||
|
loadingNodeName: string | undefined;
|
||||||
|
scopes: Scope[];
|
||||||
|
dirtyScopeNames: string[];
|
||||||
|
isLoadingScopes: boolean;
|
||||||
|
isBasicOpened: boolean;
|
||||||
|
isAdvancedOpened: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ScopesFiltersScene extends SceneObjectBase<ScopesFiltersSceneState> implements SceneObjectWithUrlSync {
|
||||||
|
static Component = ScopesFiltersSceneRenderer;
|
||||||
|
|
||||||
|
protected _urlSync = new SceneObjectUrlSyncConfig(this, { keys: ['scopes'] });
|
||||||
|
|
||||||
|
get scopesParent(): ScopesScene {
|
||||||
|
return sceneGraph.getAncestor(this, ScopesScene);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super({
|
||||||
|
nodes: {
|
||||||
|
'': {
|
||||||
|
name: '',
|
||||||
|
nodeType: 'container',
|
||||||
|
title: '',
|
||||||
|
isExpandable: true,
|
||||||
|
isSelectable: false,
|
||||||
|
isExpanded: true,
|
||||||
|
query: '',
|
||||||
|
nodes: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
loadingNodeName: undefined,
|
||||||
|
scopes: [],
|
||||||
|
dirtyScopeNames: [],
|
||||||
|
isLoadingScopes: false,
|
||||||
|
isBasicOpened: false,
|
||||||
|
isAdvancedOpened: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.addActivationHandler(() => {
|
||||||
|
this.fetchBaseNodes();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public getUrlState() {
|
||||||
|
return { scopes: this.getScopeNames() };
|
||||||
|
}
|
||||||
|
|
||||||
|
public updateFromUrl(values: SceneObjectUrlValues) {
|
||||||
|
let dirtyScopeNames = values.scopes ?? [];
|
||||||
|
dirtyScopeNames = Array.isArray(dirtyScopeNames) ? dirtyScopeNames : [dirtyScopeNames];
|
||||||
|
|
||||||
|
this.updateScopes(dirtyScopeNames);
|
||||||
|
}
|
||||||
|
|
||||||
|
public fetchBaseNodes() {
|
||||||
|
return this.updateNode([''], true, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async updateNode(path: string[], isExpanded: boolean, query: string) {
|
||||||
|
let nodes = { ...this.state.nodes };
|
||||||
|
let currentLevel: NodesMap = nodes;
|
||||||
|
|
||||||
|
for (let idx = 0; idx < path.length - 1; idx++) {
|
||||||
|
currentLevel = currentLevel[path[idx]].nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = path[path.length - 1];
|
||||||
|
const currentNode = currentLevel[name];
|
||||||
|
|
||||||
|
if (isExpanded || currentNode.query !== query) {
|
||||||
|
this.setState({ loadingNodeName: name });
|
||||||
|
|
||||||
|
currentNode.nodes = await fetchNodes(name, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
currentNode.isExpanded = isExpanded;
|
||||||
|
currentNode.query = query;
|
||||||
|
|
||||||
|
this.setState({ nodes, loadingNodeName: undefined });
|
||||||
|
}
|
||||||
|
|
||||||
|
public toggleNodeSelect(path: string[]) {
|
||||||
|
let dirtyScopeNames = [...this.state.dirtyScopeNames];
|
||||||
|
|
||||||
|
let siblings = this.state.nodes;
|
||||||
|
|
||||||
|
for (let idx = 0; idx < path.length - 1; idx++) {
|
||||||
|
siblings = siblings[path[idx]].nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = path[path.length - 1];
|
||||||
|
const { linkId } = siblings[name];
|
||||||
|
|
||||||
|
const selectedIdx = dirtyScopeNames.findIndex((scopeName) => scopeName === linkId);
|
||||||
|
|
||||||
|
if (selectedIdx === -1) {
|
||||||
|
fetchScope(linkId!);
|
||||||
|
|
||||||
|
const selectedFromSameNode =
|
||||||
|
dirtyScopeNames.length === 0 || Object.values(siblings).some(({ linkId }) => linkId === dirtyScopeNames[0]);
|
||||||
|
|
||||||
|
this.setState({ dirtyScopeNames: !selectedFromSameNode ? [linkId!] : [...dirtyScopeNames, linkId!] });
|
||||||
|
} else {
|
||||||
|
dirtyScopeNames.splice(selectedIdx, 1);
|
||||||
|
|
||||||
|
this.setState({ dirtyScopeNames });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public openBasicSelector() {
|
||||||
|
if (!this.scopesParent.state.isViewing) {
|
||||||
|
this.setState({ isBasicOpened: true, isAdvancedOpened: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public closeBasicSelector() {
|
||||||
|
this.setState({ isBasicOpened: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
public openAdvancedSelector() {
|
||||||
|
if (!this.scopesParent.state.isViewing) {
|
||||||
|
this.setState({ isBasicOpened: false, isAdvancedOpened: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public closeAdvancedSelector() {
|
||||||
|
this.setState({ isAdvancedOpened: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
public getSelectedScopes(): Scope[] {
|
||||||
|
return this.state.scopes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async updateScopes(dirtyScopeNames = this.state.dirtyScopeNames) {
|
||||||
|
if (isEqual(dirtyScopeNames, this.getScopeNames())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setState({ dirtyScopeNames, isLoadingScopes: true });
|
||||||
|
|
||||||
|
this.setState({ scopes: await fetchScopes(dirtyScopeNames), isLoadingScopes: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
public resetDirtyScopeNames() {
|
||||||
|
this.setState({ dirtyScopeNames: this.getScopeNames() });
|
||||||
|
}
|
||||||
|
|
||||||
|
public removeAllScopes() {
|
||||||
|
this.setState({ scopes: [], dirtyScopeNames: [], isLoadingScopes: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
public enterViewMode() {
|
||||||
|
this.setState({ isBasicOpened: false, isAdvancedOpened: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
private getScopeNames(): string[] {
|
||||||
|
return this.state.scopes.map(({ metadata: { name } }) => name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScopesFiltersSceneRenderer({ model }: SceneComponentProps<ScopesFiltersScene>) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ScopesFiltersBasicSelector model={model} />
|
||||||
|
<ScopesFiltersAdvancedSelector model={model} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,417 @@
|
|||||||
|
import { act, cleanup, waitFor } from '@testing-library/react';
|
||||||
|
import userEvents from '@testing-library/user-event';
|
||||||
|
|
||||||
|
import { config } from '@grafana/runtime';
|
||||||
|
import { sceneGraph } from '@grafana/scenes';
|
||||||
|
import { DashboardScene } from 'app/features/dashboard-scene/scene/DashboardScene';
|
||||||
|
|
||||||
|
import { ScopesFiltersScene } from './ScopesFiltersScene';
|
||||||
|
import { ScopesScene } from './ScopesScene';
|
||||||
|
import {
|
||||||
|
buildTestScene,
|
||||||
|
fetchDashboardsSpy,
|
||||||
|
fetchNodesSpy,
|
||||||
|
fetchScopeSpy,
|
||||||
|
fetchScopesSpy,
|
||||||
|
getAdvancedApply,
|
||||||
|
getAdvancedCancel,
|
||||||
|
getApplicationsClustersExpand,
|
||||||
|
getApplicationsClustersSelect,
|
||||||
|
getApplicationsExpand,
|
||||||
|
getApplicationsSearch,
|
||||||
|
getApplicationsSlothPictureFactorySelect,
|
||||||
|
getApplicationsSlothPictureFactoryTitle,
|
||||||
|
getApplicationsSlothVoteTrackerSelect,
|
||||||
|
getBasicInnerContainer,
|
||||||
|
getBasicInput,
|
||||||
|
getBasicOpenAdvanced,
|
||||||
|
getClustersExpand,
|
||||||
|
getClustersSelect,
|
||||||
|
getClustersSlothClusterNorthSelect,
|
||||||
|
getClustersSlothClusterSouthSelect,
|
||||||
|
getDashboard,
|
||||||
|
getDashboardsContainer,
|
||||||
|
getDashboardsSearch,
|
||||||
|
getRootExpand,
|
||||||
|
mocksNodes,
|
||||||
|
mocksScopeDashboardBindings,
|
||||||
|
mocksScopes,
|
||||||
|
queryAdvancedApply,
|
||||||
|
queryApplicationsClustersSlothClusterNorthTitle,
|
||||||
|
queryApplicationsClustersTitle,
|
||||||
|
queryApplicationsSlothPictureFactoryTitle,
|
||||||
|
queryApplicationsSlothVoteTrackerTitle,
|
||||||
|
queryBasicInnerContainer,
|
||||||
|
queryDashboard,
|
||||||
|
queryDashboardsContainer,
|
||||||
|
queryRootExpand,
|
||||||
|
renderDashboard,
|
||||||
|
} from './testUtils';
|
||||||
|
|
||||||
|
jest.mock('@grafana/runtime', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
...jest.requireActual('@grafana/runtime'),
|
||||||
|
getBackendSrv: () => ({
|
||||||
|
get: 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)
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('ScopesScene', () => {
|
||||||
|
describe('Feature flag off', () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
config.featureToggles.scopeFilters = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Does not initialize', () => {
|
||||||
|
const dashboardScene = buildTestScene();
|
||||||
|
dashboardScene.activate();
|
||||||
|
expect(dashboardScene.state.scopes).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Feature flag on', () => {
|
||||||
|
let dashboardScene: DashboardScene;
|
||||||
|
let scopesScene: ScopesScene;
|
||||||
|
let filtersScene: ScopesFiltersScene;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
config.featureToggles.scopeFilters = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.spyOn(console, 'error').mockImplementation(jest.fn());
|
||||||
|
|
||||||
|
fetchNodesSpy.mockClear();
|
||||||
|
fetchScopeSpy.mockClear();
|
||||||
|
fetchScopesSpy.mockClear();
|
||||||
|
fetchDashboardsSpy.mockClear();
|
||||||
|
|
||||||
|
dashboardScene = buildTestScene();
|
||||||
|
scopesScene = dashboardScene.state.scopes!;
|
||||||
|
filtersScene = scopesScene.state.filters;
|
||||||
|
|
||||||
|
renderDashboard(dashboardScene);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Tree', () => {
|
||||||
|
it('Navigates through scopes nodes', async () => {
|
||||||
|
await userEvents.click(getBasicInput());
|
||||||
|
await userEvents.click(getApplicationsExpand());
|
||||||
|
await userEvents.click(getApplicationsClustersExpand());
|
||||||
|
await userEvents.click(getApplicationsExpand());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Fetches scope details on select', async () => {
|
||||||
|
await userEvents.click(getBasicInput());
|
||||||
|
await userEvents.click(getApplicationsExpand());
|
||||||
|
await userEvents.click(getApplicationsSlothVoteTrackerSelect());
|
||||||
|
await waitFor(() => expect(fetchScopeSpy).toHaveBeenCalledTimes(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Selects the proper scopes', async () => {
|
||||||
|
await act(async () => filtersScene.updateScopes(['slothPictureFactory', 'slothVoteTracker']));
|
||||||
|
await userEvents.click(getBasicInput());
|
||||||
|
await userEvents.click(getApplicationsExpand());
|
||||||
|
expect(getApplicationsSlothVoteTrackerSelect()).toBeChecked();
|
||||||
|
expect(getApplicationsSlothPictureFactorySelect()).toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Can select scopes from same level', async () => {
|
||||||
|
await userEvents.click(getBasicInput());
|
||||||
|
await userEvents.click(getApplicationsExpand());
|
||||||
|
await userEvents.click(getApplicationsSlothVoteTrackerSelect());
|
||||||
|
await userEvents.click(getApplicationsSlothPictureFactorySelect());
|
||||||
|
await userEvents.click(getApplicationsClustersSelect());
|
||||||
|
await userEvents.click(getBasicInput());
|
||||||
|
expect(getBasicInput().value).toBe('slothVoteTracker, slothPictureFactory, Cluster Index Helper');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Can't navigate deeper than the level where scopes are selected", async () => {
|
||||||
|
await userEvents.click(getBasicInput());
|
||||||
|
await userEvents.click(getApplicationsExpand());
|
||||||
|
await userEvents.click(getApplicationsSlothVoteTrackerSelect());
|
||||||
|
await userEvents.click(getApplicationsClustersExpand());
|
||||||
|
expect(queryApplicationsClustersSlothClusterNorthTitle()).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Can select a node from an upper level', async () => {
|
||||||
|
await userEvents.click(getBasicInput());
|
||||||
|
await userEvents.click(getApplicationsExpand());
|
||||||
|
await userEvents.click(getApplicationsSlothVoteTrackerSelect());
|
||||||
|
await userEvents.click(getApplicationsExpand());
|
||||||
|
await userEvents.click(getClustersSelect());
|
||||||
|
await userEvents.click(getBasicInput());
|
||||||
|
expect(getBasicInput().value).toBe('Cluster Index Helper');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Respects only one select per container', async () => {
|
||||||
|
await userEvents.click(getBasicInput());
|
||||||
|
await userEvents.click(getClustersExpand());
|
||||||
|
await userEvents.click(getClustersSlothClusterNorthSelect());
|
||||||
|
expect(getClustersSlothClusterSouthSelect()).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Search works', async () => {
|
||||||
|
await userEvents.click(getBasicInput());
|
||||||
|
await userEvents.click(getBasicOpenAdvanced());
|
||||||
|
await userEvents.click(getApplicationsExpand());
|
||||||
|
await userEvents.type(getApplicationsSearch(), 'Clusters');
|
||||||
|
await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(3));
|
||||||
|
expect(queryApplicationsSlothPictureFactoryTitle()).not.toBeInTheDocument();
|
||||||
|
expect(queryApplicationsSlothVoteTrackerTitle()).not.toBeInTheDocument();
|
||||||
|
expect(getApplicationsClustersSelect()).toBeInTheDocument();
|
||||||
|
await userEvents.clear(getApplicationsSearch());
|
||||||
|
await userEvents.type(getApplicationsSearch(), 'sloth');
|
||||||
|
await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(4));
|
||||||
|
expect(getApplicationsSlothPictureFactoryTitle()).toBeInTheDocument();
|
||||||
|
expect(getApplicationsSlothVoteTrackerSelect()).toBeInTheDocument();
|
||||||
|
expect(queryApplicationsClustersTitle()).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Basic selector', () => {
|
||||||
|
it('Opens', async () => {
|
||||||
|
await userEvents.click(getBasicInput());
|
||||||
|
expect(getBasicInnerContainer()).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Fetches scope details on save', async () => {
|
||||||
|
await userEvents.click(getBasicInput());
|
||||||
|
await userEvents.click(getClustersSelect());
|
||||||
|
await userEvents.click(getBasicInput());
|
||||||
|
await waitFor(() => expect(fetchScopesSpy).toHaveBeenCalled());
|
||||||
|
expect(filtersScene.getSelectedScopes()).toEqual(
|
||||||
|
mocksScopes.filter(({ metadata: { name } }) => name === 'indexHelperCluster')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Shows selected scopes', async () => {
|
||||||
|
await userEvents.click(getBasicInput());
|
||||||
|
await userEvents.click(getClustersSelect());
|
||||||
|
await userEvents.click(getBasicInput());
|
||||||
|
expect(getBasicInput().value).toEqual('Cluster Index Helper');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Advanced selector', () => {
|
||||||
|
it('Opens', async () => {
|
||||||
|
await userEvents.click(getBasicInput());
|
||||||
|
await userEvents.click(getBasicOpenAdvanced());
|
||||||
|
expect(queryBasicInnerContainer()).not.toBeInTheDocument();
|
||||||
|
expect(getAdvancedApply()).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Fetches scope details on save', async () => {
|
||||||
|
await userEvents.click(getBasicInput());
|
||||||
|
await userEvents.click(getBasicOpenAdvanced());
|
||||||
|
await userEvents.click(getClustersSelect());
|
||||||
|
await userEvents.click(getAdvancedApply());
|
||||||
|
await waitFor(() => expect(fetchScopesSpy).toHaveBeenCalled());
|
||||||
|
expect(filtersScene.getSelectedScopes()).toEqual(
|
||||||
|
mocksScopes.filter(({ metadata: { name } }) => name === 'indexHelperCluster')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Doesn't save the scopes on close", async () => {
|
||||||
|
await userEvents.click(getBasicInput());
|
||||||
|
await userEvents.click(getBasicOpenAdvanced());
|
||||||
|
await userEvents.click(getClustersSelect());
|
||||||
|
await userEvents.click(getAdvancedCancel());
|
||||||
|
await waitFor(() => expect(fetchScopesSpy).not.toHaveBeenCalled());
|
||||||
|
expect(filtersScene.getSelectedScopes()).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Selectors interoperability', () => {
|
||||||
|
it('Replicates the same structure from basic to advanced selector', async () => {
|
||||||
|
await userEvents.click(getBasicInput());
|
||||||
|
await userEvents.click(getApplicationsExpand());
|
||||||
|
await userEvents.click(getBasicOpenAdvanced());
|
||||||
|
expect(getApplicationsSlothPictureFactoryTitle()).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Replicates the same structure from advanced to basic selector', async () => {
|
||||||
|
await userEvents.click(getBasicInput());
|
||||||
|
await userEvents.click(getBasicOpenAdvanced());
|
||||||
|
await userEvents.click(getApplicationsExpand());
|
||||||
|
await userEvents.click(getAdvancedApply());
|
||||||
|
await userEvents.click(getBasicInput());
|
||||||
|
expect(getApplicationsSlothPictureFactoryTitle()).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Dashboards list', () => {
|
||||||
|
it('Toggles expanded state', async () => {
|
||||||
|
await userEvents.click(getRootExpand());
|
||||||
|
expect(getDashboardsContainer()).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Does not fetch dashboards list when the list is not expanded', async () => {
|
||||||
|
await userEvents.click(getBasicInput());
|
||||||
|
await userEvents.click(getApplicationsExpand());
|
||||||
|
await userEvents.click(getApplicationsSlothPictureFactorySelect());
|
||||||
|
await userEvents.click(getBasicInput());
|
||||||
|
await waitFor(() => expect(fetchDashboardsSpy).not.toHaveBeenCalled());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Fetches dashboards list when the list is expanded', async () => {
|
||||||
|
await userEvents.click(getRootExpand());
|
||||||
|
await userEvents.click(getBasicInput());
|
||||||
|
await userEvents.click(getApplicationsExpand());
|
||||||
|
await userEvents.click(getApplicationsSlothPictureFactorySelect());
|
||||||
|
await userEvents.click(getBasicInput());
|
||||||
|
await waitFor(() => expect(fetchDashboardsSpy).toHaveBeenCalled());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Fetches dashboards list when the list is expanded after scope selection', async () => {
|
||||||
|
await userEvents.click(getBasicInput());
|
||||||
|
await userEvents.click(getApplicationsExpand());
|
||||||
|
await userEvents.click(getApplicationsSlothPictureFactorySelect());
|
||||||
|
await userEvents.click(getBasicInput());
|
||||||
|
await userEvents.click(getRootExpand());
|
||||||
|
await waitFor(() => expect(fetchDashboardsSpy).toHaveBeenCalled());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Shows dashboards for multiple scopes', async () => {
|
||||||
|
await userEvents.click(getRootExpand());
|
||||||
|
await userEvents.click(getBasicInput());
|
||||||
|
await userEvents.click(getApplicationsExpand());
|
||||||
|
await userEvents.click(getApplicationsSlothPictureFactorySelect());
|
||||||
|
await userEvents.click(getBasicInput());
|
||||||
|
expect(getDashboard('1')).toBeInTheDocument();
|
||||||
|
expect(getDashboard('2')).toBeInTheDocument();
|
||||||
|
expect(queryDashboard('3')).not.toBeInTheDocument();
|
||||||
|
expect(queryDashboard('4')).not.toBeInTheDocument();
|
||||||
|
await userEvents.click(getBasicInput());
|
||||||
|
await userEvents.click(getApplicationsSlothVoteTrackerSelect());
|
||||||
|
await userEvents.click(getBasicInput());
|
||||||
|
expect(getDashboard('1')).toBeInTheDocument();
|
||||||
|
expect(getDashboard('2')).toBeInTheDocument();
|
||||||
|
expect(getDashboard('3')).toBeInTheDocument();
|
||||||
|
expect(getDashboard('4')).toBeInTheDocument();
|
||||||
|
await userEvents.click(getBasicInput());
|
||||||
|
await userEvents.click(getApplicationsSlothPictureFactorySelect());
|
||||||
|
await userEvents.click(getBasicInput());
|
||||||
|
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(getRootExpand());
|
||||||
|
await userEvents.click(getBasicInput());
|
||||||
|
await userEvents.click(getApplicationsExpand());
|
||||||
|
await userEvents.click(getApplicationsSlothPictureFactorySelect());
|
||||||
|
await userEvents.click(getBasicInput());
|
||||||
|
expect(getDashboard('1')).toBeInTheDocument();
|
||||||
|
expect(getDashboard('2')).toBeInTheDocument();
|
||||||
|
await userEvents.type(getDashboardsSearch(), '1');
|
||||||
|
expect(queryDashboard('2')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('View mode', () => {
|
||||||
|
it('Enters view mode', async () => {
|
||||||
|
await act(async () => dashboardScene.onEnterEditMode());
|
||||||
|
expect(scopesScene.state.isViewing).toEqual(true);
|
||||||
|
expect(scopesScene.state.isExpanded).toEqual(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Closes basic selector on enter', async () => {
|
||||||
|
await userEvents.click(getBasicInput());
|
||||||
|
await act(async () => dashboardScene.onEnterEditMode());
|
||||||
|
expect(queryBasicInnerContainer()).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Closes advanced selector on enter', async () => {
|
||||||
|
await userEvents.click(getBasicInput());
|
||||||
|
await userEvents.click(getBasicOpenAdvanced());
|
||||||
|
await act(async () => dashboardScene.onEnterEditMode());
|
||||||
|
expect(queryAdvancedApply()).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Closes dashboards list on enter', async () => {
|
||||||
|
await userEvents.click(getRootExpand());
|
||||||
|
await act(async () => dashboardScene.onEnterEditMode());
|
||||||
|
expect(queryDashboardsContainer()).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Does not open basic selector when view mode is active', async () => {
|
||||||
|
await act(async () => dashboardScene.onEnterEditMode());
|
||||||
|
await userEvents.click(getBasicInput());
|
||||||
|
expect(queryBasicInnerContainer()).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Hides the expand button when view mode is active', async () => {
|
||||||
|
await act(async () => dashboardScene.onEnterEditMode());
|
||||||
|
expect(queryRootExpand()).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Data requests', () => {
|
||||||
|
it('Enriches data requests', async () => {
|
||||||
|
await userEvents.click(getBasicInput());
|
||||||
|
await userEvents.click(getApplicationsExpand());
|
||||||
|
await userEvents.click(getApplicationsSlothPictureFactorySelect());
|
||||||
|
await userEvents.click(getBasicInput());
|
||||||
|
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(getBasicInput());
|
||||||
|
await userEvents.click(getApplicationsSlothVoteTrackerSelect());
|
||||||
|
await userEvents.click(getBasicInput());
|
||||||
|
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(getBasicInput());
|
||||||
|
await userEvents.click(getApplicationsSlothPictureFactorySelect());
|
||||||
|
await userEvents.click(getBasicInput());
|
||||||
|
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')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -4,6 +4,7 @@ import React from 'react';
|
|||||||
import { GrafanaTheme2 } from '@grafana/data';
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
import { SceneComponentProps, sceneGraph, SceneObjectBase, SceneObjectState } from '@grafana/scenes';
|
import { SceneComponentProps, sceneGraph, SceneObjectBase, SceneObjectState } from '@grafana/scenes';
|
||||||
import { IconButton, useStyles2 } from '@grafana/ui';
|
import { IconButton, useStyles2 } from '@grafana/ui';
|
||||||
|
import { t } from 'app/core/internationalization';
|
||||||
|
|
||||||
import { ScopesDashboardsScene } from './ScopesDashboardsScene';
|
import { ScopesDashboardsScene } from './ScopesDashboardsScene';
|
||||||
import { ScopesFiltersScene } from './ScopesFiltersScene';
|
import { ScopesFiltersScene } from './ScopesFiltersScene';
|
||||||
@ -27,16 +28,20 @@ export class ScopesScene extends SceneObjectBase<ScopesSceneState> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.addActivationHandler(() => {
|
this.addActivationHandler(() => {
|
||||||
this.state.filters.fetchBaseNodes();
|
this._subs.add(
|
||||||
|
this.state.filters.subscribeToState((newState, prevState) => {
|
||||||
const filtersValueSubscription = this.state.filters.subscribeToState((newState, prevState) => {
|
|
||||||
if (newState.scopes !== prevState.scopes) {
|
if (newState.scopes !== prevState.scopes) {
|
||||||
|
if (this.state.isExpanded) {
|
||||||
this.state.dashboards.fetchDashboards(newState.scopes);
|
this.state.dashboards.fetchDashboards(newState.scopes);
|
||||||
|
}
|
||||||
|
|
||||||
sceneGraph.getTimeRange(this.parent!).onRefresh();
|
sceneGraph.getTimeRange(this.parent!).onRefresh();
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
);
|
||||||
|
|
||||||
const dashboardEditModeSubscription = this.parent?.subscribeToState((newState) => {
|
this._subs.add(
|
||||||
|
this.parent?.subscribeToState((newState) => {
|
||||||
const isEditing = 'isEditing' in newState ? !!newState.isEditing : false;
|
const isEditing = 'isEditing' in newState ? !!newState.isEditing : false;
|
||||||
|
|
||||||
if (isEditing !== this.state.isViewing) {
|
if (isEditing !== this.state.isViewing) {
|
||||||
@ -46,25 +51,29 @@ export class ScopesScene extends SceneObjectBase<ScopesSceneState> {
|
|||||||
this.exitViewMode();
|
this.exitViewMode();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
);
|
||||||
return () => {
|
|
||||||
filtersValueSubscription.unsubscribe();
|
|
||||||
dashboardEditModeSubscription?.unsubscribe();
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public getSelectedScopes() {
|
public getSelectedScopes() {
|
||||||
return this.state.filters.state.scopes;
|
return this.state.filters.getSelectedScopes();
|
||||||
}
|
}
|
||||||
|
|
||||||
public toggleIsExpanded() {
|
public toggleIsExpanded() {
|
||||||
this.setState({ isExpanded: !this.state.isExpanded });
|
const isExpanded = !this.state.isExpanded;
|
||||||
|
|
||||||
|
if (isExpanded) {
|
||||||
|
this.state.dashboards.fetchDashboards(this.getSelectedScopes());
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setState({ isExpanded });
|
||||||
}
|
}
|
||||||
|
|
||||||
private enterViewMode() {
|
private enterViewMode() {
|
||||||
this.setState({ isExpanded: false, isViewing: true });
|
this.setState({ isExpanded: false, isViewing: true });
|
||||||
|
|
||||||
|
this.state.filters.enterViewMode();
|
||||||
}
|
}
|
||||||
|
|
||||||
private exitViewMode() {
|
private exitViewMode() {
|
||||||
@ -82,17 +91,21 @@ export function ScopesSceneRenderer({ model }: SceneComponentProps<ScopesScene>)
|
|||||||
{!isViewing && (
|
{!isViewing && (
|
||||||
<IconButton
|
<IconButton
|
||||||
name="arrow-to-right"
|
name="arrow-to-right"
|
||||||
aria-label={isExpanded ? 'Collapse scope filters' : 'Expand scope filters'}
|
|
||||||
className={cx(!isExpanded && styles.iconNotExpanded)}
|
className={cx(!isExpanded && styles.iconNotExpanded)}
|
||||||
data-testid="scopes-scene-toggle-expand-button"
|
aria-label={
|
||||||
|
isExpanded
|
||||||
|
? t('scopes.root.collapse', 'Collapse scope filters')
|
||||||
|
: t('scopes.root.expand', 'Expand scope filters')
|
||||||
|
}
|
||||||
|
data-testid="scopes-root-expand"
|
||||||
onClick={() => model.toggleIsExpanded()}
|
onClick={() => model.toggleIsExpanded()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<filters.Component model={filters} />
|
<filters.Component model={filters} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isExpanded && (
|
{isExpanded && !isViewing && (
|
||||||
<div className={styles.dashboardsContainer} data-testid="scopes-scene-dashboards-container">
|
<div className={styles.dashboardsContainer} data-testid="scopes-dashboards-container">
|
||||||
<dashboards.Component model={dashboards} />
|
<dashboards.Component model={dashboards} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
@ -0,0 +1,141 @@
|
|||||||
|
import { css } from '@emotion/css';
|
||||||
|
import { debounce } from 'lodash';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
|
import { Checkbox, Icon, IconButton, Input, useStyles2 } from '@grafana/ui';
|
||||||
|
import { t } from 'app/core/internationalization';
|
||||||
|
|
||||||
|
import { NodesMap } from './types';
|
||||||
|
|
||||||
|
export interface ScopesTreeLevelProps {
|
||||||
|
showQuery: boolean;
|
||||||
|
nodes: NodesMap;
|
||||||
|
nodePath: string[];
|
||||||
|
loadingNodeName: string | undefined;
|
||||||
|
scopeNames: string[];
|
||||||
|
onNodeUpdate: (path: string[], isExpanded: boolean, query: string) => void;
|
||||||
|
onNodeSelectToggle: (path: string[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScopesTreeLevel({
|
||||||
|
showQuery,
|
||||||
|
nodes,
|
||||||
|
nodePath,
|
||||||
|
loadingNodeName,
|
||||||
|
scopeNames,
|
||||||
|
onNodeUpdate,
|
||||||
|
onNodeSelectToggle,
|
||||||
|
}: ScopesTreeLevelProps) {
|
||||||
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
|
const nodeId = nodePath[nodePath.length - 1];
|
||||||
|
const node = nodes[nodeId];
|
||||||
|
const childNodes = node.nodes;
|
||||||
|
const childNodesArr = Object.values(childNodes);
|
||||||
|
|
||||||
|
const anyChildExpanded = childNodesArr.some(({ isExpanded }) => isExpanded);
|
||||||
|
const anyChildSelected = childNodesArr.some(({ linkId }) => linkId && scopeNames.includes(linkId!));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{showQuery && !anyChildExpanded && (
|
||||||
|
<Input
|
||||||
|
prefix={<Icon name="filter" />}
|
||||||
|
className={styles.searchInput}
|
||||||
|
disabled={!!loadingNodeName}
|
||||||
|
placeholder={t('scopes.tree.search', 'Filter')}
|
||||||
|
defaultValue={node.query}
|
||||||
|
data-testid={`scopes-tree-${nodeId}-search`}
|
||||||
|
onChange={debounce((evt) => {
|
||||||
|
onNodeUpdate(nodePath, true, evt.target.value);
|
||||||
|
}, 500)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div role="tree">
|
||||||
|
{childNodesArr.map((childNode) => {
|
||||||
|
const isSelected = childNode.isSelectable && scopeNames.includes(childNode.linkId!);
|
||||||
|
|
||||||
|
if (anyChildExpanded && !childNode.isExpanded && !isSelected) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const childNodePath = [...nodePath, childNode.name];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={childNode.name} role="treeitem" aria-selected={childNode.isExpanded}>
|
||||||
|
<div className={styles.itemTitle}>
|
||||||
|
{childNode.isSelectable && !childNode.isExpanded ? (
|
||||||
|
<Checkbox
|
||||||
|
checked={isSelected}
|
||||||
|
disabled={!!loadingNodeName || (anyChildSelected && !isSelected && node.disableMultiSelect)}
|
||||||
|
data-testid={`scopes-tree-${childNode.name}-checkbox`}
|
||||||
|
onChange={() => {
|
||||||
|
onNodeSelectToggle(childNodePath);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{childNode.isExpandable && (
|
||||||
|
<IconButton
|
||||||
|
disabled={(anyChildSelected && !childNode.isExpanded) || !!loadingNodeName}
|
||||||
|
name={
|
||||||
|
!childNode.isExpanded
|
||||||
|
? 'angle-right'
|
||||||
|
: loadingNodeName === childNode.name
|
||||||
|
? 'spinner'
|
||||||
|
: 'angle-down'
|
||||||
|
}
|
||||||
|
aria-label={
|
||||||
|
childNode.isExpanded ? t('scopes.tree.collapse', 'Collapse') : t('scopes.tree.expand', 'Expand')
|
||||||
|
}
|
||||||
|
data-testid={`scopes-tree-${childNode.name}-expand`}
|
||||||
|
onClick={() => {
|
||||||
|
onNodeUpdate(childNodePath, !childNode.isExpanded, childNode.query);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span data-testid={`scopes-tree-${childNode.name}-title`}>{childNode.title}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.itemChildren}>
|
||||||
|
{childNode.isExpanded && (
|
||||||
|
<ScopesTreeLevel
|
||||||
|
showQuery={showQuery}
|
||||||
|
nodes={node.nodes}
|
||||||
|
nodePath={childNodePath}
|
||||||
|
loadingNodeName={loadingNodeName}
|
||||||
|
scopeNames={scopeNames}
|
||||||
|
onNodeUpdate={onNodeUpdate}
|
||||||
|
onNodeSelectToggle={onNodeSelectToggle}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStyles = (theme: GrafanaTheme2) => {
|
||||||
|
return {
|
||||||
|
searchInput: css({
|
||||||
|
margin: theme.spacing(1, 0),
|
||||||
|
}),
|
||||||
|
itemTitle: css({
|
||||||
|
alignItems: 'center',
|
||||||
|
display: 'flex',
|
||||||
|
gap: theme.spacing(1),
|
||||||
|
fontSize: theme.typography.pxToRem(14),
|
||||||
|
lineHeight: theme.typography.pxToRem(22),
|
||||||
|
padding: theme.spacing(0.5, 0),
|
||||||
|
}),
|
||||||
|
itemChildren: css({
|
||||||
|
paddingLeft: theme.spacing(4),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
};
|
103
public/app/features/dashboard-scene/scene/Scopes/api.ts
Normal file
103
public/app/features/dashboard-scene/scene/Scopes/api.ts
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
import { Scope, ScopeSpec, ScopeNode, ScopeDashboardBinding } from '@grafana/data';
|
||||||
|
import { config, getBackendSrv } from '@grafana/runtime';
|
||||||
|
import { ScopedResourceClient } from 'app/features/apiserver/client';
|
||||||
|
import { NodesMap } from 'app/features/dashboard-scene/scene/Scopes/types';
|
||||||
|
|
||||||
|
const group = 'scope.grafana.app';
|
||||||
|
const version = 'v0alpha1';
|
||||||
|
const namespace = config.namespace ?? 'default';
|
||||||
|
|
||||||
|
const nodesEndpoint = `/apis/${group}/${version}/namespaces/${namespace}/find/scope_node_children`;
|
||||||
|
const dashboardsEndpoint = `/apis/${group}/${version}/namespaces/${namespace}/find/scope_dashboard_bindings`;
|
||||||
|
|
||||||
|
const scopesClient = new ScopedResourceClient<ScopeSpec, 'Scope'>({
|
||||||
|
group,
|
||||||
|
version,
|
||||||
|
resource: 'scopes',
|
||||||
|
});
|
||||||
|
|
||||||
|
const scopesCache = new Map<string, Promise<Scope>>();
|
||||||
|
|
||||||
|
async function fetchScopeNodes(parent: string, query: string): Promise<ScopeNode[]> {
|
||||||
|
try {
|
||||||
|
return (await getBackendSrv().get<{ items: ScopeNode[] }>(nodesEndpoint, { parent, query }))?.items ?? [];
|
||||||
|
} catch (err) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchNodes(parent: string, query: string): Promise<NodesMap> {
|
||||||
|
return (await fetchScopeNodes(parent, query)).reduce<NodesMap>((acc, { metadata: { name }, spec }) => {
|
||||||
|
acc[name] = {
|
||||||
|
name,
|
||||||
|
...spec,
|
||||||
|
isExpandable: spec.nodeType === 'container',
|
||||||
|
isSelectable: spec.linkType === 'scope',
|
||||||
|
isExpanded: false,
|
||||||
|
query: '',
|
||||||
|
nodes: {},
|
||||||
|
};
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchScope(name: string): Promise<Scope> {
|
||||||
|
if (scopesCache.has(name)) {
|
||||||
|
return scopesCache.get(name)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = new Promise<Scope>(async (resolve) => {
|
||||||
|
const basicScope: Scope = {
|
||||||
|
metadata: { name },
|
||||||
|
spec: {
|
||||||
|
filters: [],
|
||||||
|
title: name,
|
||||||
|
type: '',
|
||||||
|
category: '',
|
||||||
|
description: '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const serverScope = await scopesClient.get(name);
|
||||||
|
|
||||||
|
const scope = {
|
||||||
|
...basicScope,
|
||||||
|
metadata: {
|
||||||
|
...basicScope.metadata,
|
||||||
|
...serverScope.metadata,
|
||||||
|
},
|
||||||
|
spec: {
|
||||||
|
...basicScope.spec,
|
||||||
|
...serverScope.spec,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
resolve(scope);
|
||||||
|
} catch (err) {
|
||||||
|
scopesCache.delete(name);
|
||||||
|
|
||||||
|
resolve(basicScope);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
scopesCache.set(name, response);
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchScopes(names: string[]): Promise<Scope[]> {
|
||||||
|
return await Promise.all(names.map(fetchScope));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchDashboards(scopes: Scope[]): Promise<ScopeDashboardBinding[]> {
|
||||||
|
try {
|
||||||
|
const response = await getBackendSrv().get<{ items: ScopeDashboardBinding[] }>(dashboardsEndpoint, {
|
||||||
|
scope: scopes.map(({ metadata: { name } }) => name),
|
||||||
|
});
|
||||||
|
|
||||||
|
return response?.items ?? [];
|
||||||
|
} catch (err) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
330
public/app/features/dashboard-scene/scene/Scopes/testUtils.tsx
Normal file
330
public/app/features/dashboard-scene/scene/Scopes/testUtils.tsx
Normal file
@ -0,0 +1,330 @@
|
|||||||
|
import { screen } from '@testing-library/react';
|
||||||
|
import React from 'react';
|
||||||
|
import { render } from 'test/test-utils';
|
||||||
|
|
||||||
|
import { Scope, ScopeDashboardBinding, ScopeNode } from '@grafana/data';
|
||||||
|
import { behaviors, SceneGridItem, SceneGridLayout, SceneQueryRunner, SceneTimeRange, VizPanel } from '@grafana/scenes';
|
||||||
|
import { DashboardControls } from 'app/features/dashboard-scene/scene//DashboardControls';
|
||||||
|
import { DashboardScene } from 'app/features/dashboard-scene/scene/DashboardScene';
|
||||||
|
|
||||||
|
import * as api from './api';
|
||||||
|
|
||||||
|
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: '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' },
|
||||||
|
},
|
||||||
|
] 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.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 fetchScopesSpy = jest.spyOn(api, 'fetchScopes');
|
||||||
|
export const fetchDashboardsSpy = jest.spyOn(api, 'fetchDashboards');
|
||||||
|
|
||||||
|
const selectors = {
|
||||||
|
root: {
|
||||||
|
expand: 'scopes-root-expand',
|
||||||
|
},
|
||||||
|
tree: {
|
||||||
|
search: (nodeId: string) => `scopes-tree-${nodeId}-search`,
|
||||||
|
select: (nodeId: string) => `scopes-tree-${nodeId}-checkbox`,
|
||||||
|
expand: (nodeId: string) => `scopes-tree-${nodeId}-expand`,
|
||||||
|
title: (nodeId: string) => `scopes-tree-${nodeId}-title`,
|
||||||
|
},
|
||||||
|
basicSelector: {
|
||||||
|
container: 'scopes-basic-container',
|
||||||
|
innerContainer: 'scopes-basic-inner-container',
|
||||||
|
loading: 'scopes-basic-loading',
|
||||||
|
openAdvanced: 'scopes-basic-open-advanced',
|
||||||
|
input: 'scopes-basic-input',
|
||||||
|
},
|
||||||
|
advancedSelector: {
|
||||||
|
container: 'scopes-advanced-container',
|
||||||
|
loading: 'scopes-advanced-loading',
|
||||||
|
apply: 'scopes-advanced-apply',
|
||||||
|
cancel: 'scopes-advanced-cancel',
|
||||||
|
},
|
||||||
|
dashboards: {
|
||||||
|
container: 'scopes-dashboards-container',
|
||||||
|
search: 'scopes-dashboards-search',
|
||||||
|
loading: 'scopes-dashboards-loading',
|
||||||
|
dashboard: (uid: string) => `scopes-dashboards-${uid}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const queryRootExpand = () => screen.queryByTestId(selectors.root.expand);
|
||||||
|
export const getRootExpand = () => screen.getByTestId(selectors.root.expand);
|
||||||
|
|
||||||
|
export const queryBasicInnerContainer = () => screen.queryByTestId(selectors.basicSelector.innerContainer);
|
||||||
|
export const getBasicInnerContainer = () => screen.getByTestId(selectors.basicSelector.innerContainer);
|
||||||
|
export const getBasicInput = () => screen.getByTestId<HTMLInputElement>(selectors.basicSelector.input);
|
||||||
|
export const getBasicOpenAdvanced = () => screen.getByTestId(selectors.basicSelector.openAdvanced);
|
||||||
|
|
||||||
|
export const queryAdvancedApply = () => screen.queryByTestId(selectors.advancedSelector.apply);
|
||||||
|
export const getAdvancedApply = () => screen.getByTestId(selectors.advancedSelector.apply);
|
||||||
|
export const getAdvancedCancel = () => screen.getByTestId(selectors.advancedSelector.cancel);
|
||||||
|
|
||||||
|
export const queryDashboardsContainer = () => screen.queryByTestId(selectors.dashboards.container);
|
||||||
|
export const getDashboardsContainer = () => screen.getByTestId(selectors.dashboards.container);
|
||||||
|
export const getDashboardsSearch = () => screen.getByTestId(selectors.dashboards.search);
|
||||||
|
export const queryDashboard = (uid: string) => screen.queryByTestId(selectors.dashboards.dashboard(uid));
|
||||||
|
export const getDashboard = (uid: string) => screen.getByTestId(selectors.dashboards.dashboard(uid));
|
||||||
|
|
||||||
|
export const getApplicationsExpand = () => screen.getByTestId(selectors.tree.expand('applications'));
|
||||||
|
export const getApplicationsSearch = () => screen.getByTestId(selectors.tree.search('applications'));
|
||||||
|
export const queryApplicationsSlothPictureFactoryTitle = () =>
|
||||||
|
screen.queryByTestId(selectors.tree.title('applications-slothPictureFactory'));
|
||||||
|
export const getApplicationsSlothPictureFactoryTitle = () =>
|
||||||
|
screen.getByTestId(selectors.tree.title('applications-slothPictureFactory'));
|
||||||
|
export const getApplicationsSlothPictureFactorySelect = () =>
|
||||||
|
screen.getByTestId(selectors.tree.select('applications-slothPictureFactory'));
|
||||||
|
export const queryApplicationsSlothVoteTrackerTitle = () =>
|
||||||
|
screen.queryByTestId(selectors.tree.title('applications-slothVoteTracker'));
|
||||||
|
export const getApplicationsSlothVoteTrackerSelect = () =>
|
||||||
|
screen.getByTestId(selectors.tree.select('applications-slothVoteTracker'));
|
||||||
|
export const queryApplicationsClustersTitle = () => screen.queryByTestId(selectors.tree.title('applications.clusters'));
|
||||||
|
export const getApplicationsClustersSelect = () => screen.getByTestId(selectors.tree.select('applications.clusters'));
|
||||||
|
export const getApplicationsClustersExpand = () => screen.getByTestId(selectors.tree.expand('applications.clusters'));
|
||||||
|
export const queryApplicationsClustersSlothClusterNorthTitle = () =>
|
||||||
|
screen.queryByTestId(selectors.tree.title('applications.clusters-slothClusterNorth'));
|
||||||
|
|
||||||
|
export const getClustersSelect = () => screen.getByTestId(selectors.tree.select('clusters'));
|
||||||
|
export const getClustersExpand = () => screen.getByTestId(selectors.tree.expand('clusters'));
|
||||||
|
export const getClustersSlothClusterNorthSelect = () =>
|
||||||
|
screen.getByTestId(selectors.tree.select('clusters-slothClusterNorth'));
|
||||||
|
export const getClustersSlothClusterSouthSelect = () =>
|
||||||
|
screen.getByTestId(selectors.tree.select('clusters-slothClusterSouth'));
|
||||||
|
|
||||||
|
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({})],
|
||||||
|
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(<dashboardScene.Component model={dashboardScene} />);
|
||||||
|
}
|
12
public/app/features/dashboard-scene/scene/Scopes/types.ts
Normal file
12
public/app/features/dashboard-scene/scene/Scopes/types.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { ScopeNodeSpec } from '@grafana/data';
|
||||||
|
|
||||||
|
export interface Node extends ScopeNodeSpec {
|
||||||
|
name: string;
|
||||||
|
isExpandable: boolean;
|
||||||
|
isSelectable: boolean;
|
||||||
|
isExpanded: boolean;
|
||||||
|
query: string;
|
||||||
|
nodes: NodesMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NodesMap = Record<string, Node>;
|
@ -1,167 +0,0 @@
|
|||||||
import { css } from '@emotion/css';
|
|
||||||
import React from 'react';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
|
|
||||||
import { AppEvents, GrafanaTheme2, Scope, ScopeDashboardBindingSpec, urlUtil } from '@grafana/data';
|
|
||||||
import { getAppEvents, getBackendSrv } from '@grafana/runtime';
|
|
||||||
import { SceneComponentProps, SceneObjectBase, SceneObjectState } from '@grafana/scenes';
|
|
||||||
import { CustomScrollbar, Icon, Input, useStyles2 } from '@grafana/ui';
|
|
||||||
import { useQueryParams } from 'app/core/hooks/useQueryParams';
|
|
||||||
import { ScopedResourceClient } from 'app/features/apiserver/client';
|
|
||||||
|
|
||||||
export interface ScopeDashboard {
|
|
||||||
uid: string;
|
|
||||||
title: string;
|
|
||||||
url: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ScopesDashboardsSceneState extends SceneObjectState {
|
|
||||||
dashboards: ScopeDashboard[];
|
|
||||||
filteredDashboards: ScopeDashboard[];
|
|
||||||
isLoading: boolean;
|
|
||||||
searchQuery: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ScopesDashboardsScene extends SceneObjectBase<ScopesDashboardsSceneState> {
|
|
||||||
static Component = ScopesDashboardsSceneRenderer;
|
|
||||||
|
|
||||||
private server = new ScopedResourceClient<ScopeDashboardBindingSpec, 'ScopeDashboardBinding'>({
|
|
||||||
group: 'scope.grafana.app',
|
|
||||||
version: 'v0alpha1',
|
|
||||||
resource: 'scopedashboardbindings',
|
|
||||||
});
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
super({
|
|
||||||
dashboards: [],
|
|
||||||
filteredDashboards: [],
|
|
||||||
isLoading: false,
|
|
||||||
searchQuery: '',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public async fetchDashboards(scopes: Scope[]) {
|
|
||||||
if (scopes.length === 0) {
|
|
||||||
return this.setState({ dashboards: [], filteredDashboards: [], isLoading: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
this.setState({ isLoading: true });
|
|
||||||
|
|
||||||
const dashboardUids = await Promise.all(
|
|
||||||
scopes.map((scope) => this.fetchDashboardsUids(scope.metadata.name).catch(() => []))
|
|
||||||
);
|
|
||||||
const dashboards = await this.fetchDashboardsDetails(dashboardUids.flat());
|
|
||||||
|
|
||||||
this.setState({
|
|
||||||
dashboards,
|
|
||||||
filteredDashboards: this.filterDashboards(dashboards, this.state.searchQuery),
|
|
||||||
isLoading: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public changeSearchQuery(searchQuery: string) {
|
|
||||||
this.setState({
|
|
||||||
filteredDashboards: searchQuery
|
|
||||||
? this.filterDashboards(this.state.dashboards, searchQuery)
|
|
||||||
: this.state.dashboards,
|
|
||||||
searchQuery: searchQuery ?? '',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async fetchDashboardsUids(scope: string): Promise<string[]> {
|
|
||||||
try {
|
|
||||||
const response = await this.server.list({
|
|
||||||
fieldSelector: [
|
|
||||||
{
|
|
||||||
key: 'spec.scope',
|
|
||||||
operator: '=',
|
|
||||||
value: scope,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
return response.items.map((item) => item.spec.dashboard).filter((dashboardUid) => !!dashboardUid) ?? [];
|
|
||||||
} catch (err) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async fetchDashboardsDetails(dashboardUids: string[]): Promise<ScopeDashboard[]> {
|
|
||||||
try {
|
|
||||||
const dashboards = await Promise.all(
|
|
||||||
dashboardUids.map((dashboardUid) => this.fetchDashboardDetails(dashboardUid))
|
|
||||||
);
|
|
||||||
|
|
||||||
return dashboards.filter((dashboard): dashboard is ScopeDashboard => !!dashboard);
|
|
||||||
} catch (err) {
|
|
||||||
getAppEvents().publish({
|
|
||||||
type: AppEvents.alertError.name,
|
|
||||||
payload: ['Failed to fetch suggested dashboards'],
|
|
||||||
});
|
|
||||||
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async fetchDashboardDetails(dashboardUid: string): Promise<ScopeDashboard | undefined> {
|
|
||||||
try {
|
|
||||||
const dashboard = await getBackendSrv().get(`/api/dashboards/uid/${dashboardUid}`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
uid: dashboard.dashboard.uid,
|
|
||||||
title: dashboard.dashboard.title,
|
|
||||||
url: dashboard.meta.url,
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private filterDashboards(dashboards: ScopeDashboard[], searchQuery: string) {
|
|
||||||
const lowerCasedSearchQuery = searchQuery.toLowerCase();
|
|
||||||
return dashboards.filter((dashboard) => dashboard.title.toLowerCase().includes(lowerCasedSearchQuery));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ScopesDashboardsSceneRenderer({ model }: SceneComponentProps<ScopesDashboardsScene>) {
|
|
||||||
const { filteredDashboards, isLoading } = model.useState();
|
|
||||||
const styles = useStyles2(getStyles);
|
|
||||||
|
|
||||||
const [queryParams] = useQueryParams();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className={styles.searchInputContainer}>
|
|
||||||
<Input
|
|
||||||
prefix={<Icon name="search" />}
|
|
||||||
disabled={isLoading}
|
|
||||||
onChange={(evt) => model.changeSearchQuery(evt.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<CustomScrollbar>
|
|
||||||
{filteredDashboards.map((dashboard, idx) => (
|
|
||||||
<div key={idx} className={styles.dashboardItem}>
|
|
||||||
<Link to={urlUtil.renderUrl(dashboard.url, queryParams)}>{dashboard.title}</Link>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</CustomScrollbar>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const getStyles = (theme: GrafanaTheme2) => {
|
|
||||||
return {
|
|
||||||
searchInputContainer: css({
|
|
||||||
flex: '0 1 auto',
|
|
||||||
}),
|
|
||||||
dashboardItem: css({
|
|
||||||
padding: theme.spacing(1, 0),
|
|
||||||
borderBottom: `1px solid ${theme.colors.border.weak}`,
|
|
||||||
|
|
||||||
':first-child': {
|
|
||||||
paddingTop: 0,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
};
|
|
@ -1,309 +0,0 @@
|
|||||||
import { css, cx } from '@emotion/css';
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
import { AppEvents, GrafanaTheme2, Scope, ScopeSpec, ScopeTreeItemSpec } from '@grafana/data';
|
|
||||||
import { config, getAppEvents, getBackendSrv } from '@grafana/runtime';
|
|
||||||
import {
|
|
||||||
SceneComponentProps,
|
|
||||||
SceneObjectBase,
|
|
||||||
SceneObjectState,
|
|
||||||
SceneObjectUrlSyncConfig,
|
|
||||||
SceneObjectUrlValues,
|
|
||||||
} from '@grafana/scenes';
|
|
||||||
import { Checkbox, Icon, Input, Toggletip, useStyles2 } from '@grafana/ui';
|
|
||||||
import { ScopedResourceClient } from 'app/features/apiserver/client';
|
|
||||||
|
|
||||||
export interface Node {
|
|
||||||
item: ScopeTreeItemSpec;
|
|
||||||
isScope: boolean;
|
|
||||||
children: Record<string, Node>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ScopesFiltersSceneState extends SceneObjectState {
|
|
||||||
nodes: Record<string, Node>;
|
|
||||||
expandedNodes: string[];
|
|
||||||
scopes: Scope[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ScopesFiltersScene extends SceneObjectBase<ScopesFiltersSceneState> {
|
|
||||||
static Component = ScopesFiltersSceneRenderer;
|
|
||||||
|
|
||||||
protected _urlSync = new SceneObjectUrlSyncConfig(this, { keys: ['scopes'] });
|
|
||||||
|
|
||||||
private serverGroup = 'scope.grafana.app';
|
|
||||||
private serverVersion = 'v0alpha1';
|
|
||||||
private serverNamespace = config.namespace;
|
|
||||||
|
|
||||||
private server = new ScopedResourceClient<ScopeSpec, 'Scope'>({
|
|
||||||
group: this.serverGroup,
|
|
||||||
version: this.serverVersion,
|
|
||||||
resource: 'scopes',
|
|
||||||
});
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
super({
|
|
||||||
nodes: {},
|
|
||||||
expandedNodes: [],
|
|
||||||
scopes: [],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
getUrlState() {
|
|
||||||
return { scopes: this.state.scopes.map((scope) => scope.metadata.name) };
|
|
||||||
}
|
|
||||||
|
|
||||||
updateFromUrl(values: SceneObjectUrlValues) {
|
|
||||||
let scopesNames = values.scopes ?? [];
|
|
||||||
scopesNames = Array.isArray(scopesNames) ? scopesNames : [scopesNames];
|
|
||||||
|
|
||||||
const scopesPromises = scopesNames.map((scopeName) => this.server.get(scopeName));
|
|
||||||
|
|
||||||
Promise.all(scopesPromises).then((scopes) => {
|
|
||||||
this.setState({
|
|
||||||
scopes: scopesNames.map((scopeName, scopeNameIdx) =>
|
|
||||||
this.mergeScopeNameWithScopes(scopeName, scopes[scopeNameIdx] ?? {})
|
|
||||||
),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public async fetchTreeItems(nodeId: string): Promise<Record<string, Node>> {
|
|
||||||
try {
|
|
||||||
return (
|
|
||||||
(
|
|
||||||
await getBackendSrv().get<{ items: ScopeTreeItemSpec[] }>(
|
|
||||||
`/apis/${this.serverGroup}/${this.serverVersion}/namespaces/${this.serverNamespace}/find`,
|
|
||||||
{ parent: nodeId }
|
|
||||||
)
|
|
||||||
)?.items ?? []
|
|
||||||
).reduce<Record<string, Node>>((acc, item) => {
|
|
||||||
acc[item.nodeId] = {
|
|
||||||
item,
|
|
||||||
isScope: item.nodeType === 'leaf',
|
|
||||||
children: {},
|
|
||||||
};
|
|
||||||
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
} catch (err) {
|
|
||||||
getAppEvents().publish({
|
|
||||||
type: AppEvents.alertError.name,
|
|
||||||
payload: ['Failed to fetch tree items'],
|
|
||||||
});
|
|
||||||
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async fetchScopes(parent: string) {
|
|
||||||
try {
|
|
||||||
return (await this.server.list({ labelSelector: [{ key: 'category', operator: '=', value: parent }] }))?.items;
|
|
||||||
} catch (err) {
|
|
||||||
getAppEvents().publish({
|
|
||||||
type: AppEvents.alertError.name,
|
|
||||||
payload: ['Failed to fetch scopes'],
|
|
||||||
});
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async expandNode(path: string[]) {
|
|
||||||
let nodes = { ...this.state.nodes };
|
|
||||||
let currentLevel = nodes;
|
|
||||||
|
|
||||||
for (let idx = 0; idx < path.length; idx++) {
|
|
||||||
const nodeId = path[idx];
|
|
||||||
const isLast = idx === path.length - 1;
|
|
||||||
const currentNode = currentLevel[nodeId];
|
|
||||||
|
|
||||||
currentLevel[nodeId] = {
|
|
||||||
...currentNode,
|
|
||||||
children: isLast ? await this.fetchTreeItems(nodeId) : currentLevel[nodeId].children,
|
|
||||||
};
|
|
||||||
|
|
||||||
currentLevel = currentNode.children;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.setState({
|
|
||||||
nodes,
|
|
||||||
expandedNodes: path,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public async fetchBaseNodes() {
|
|
||||||
this.setState({
|
|
||||||
nodes: await this.fetchTreeItems(''),
|
|
||||||
expandedNodes: [],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public async toggleScope(linkId: string) {
|
|
||||||
let scopes = [...this.state.scopes];
|
|
||||||
const selectedIdx = scopes.findIndex((scope) => scope.metadata.name === linkId);
|
|
||||||
|
|
||||||
if (selectedIdx === -1) {
|
|
||||||
const receivedScope = await this.server.get(linkId);
|
|
||||||
|
|
||||||
scopes.push(this.mergeScopeNameWithScopes(linkId, receivedScope ?? {}));
|
|
||||||
} else {
|
|
||||||
scopes.splice(selectedIdx, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.setState({ scopes });
|
|
||||||
}
|
|
||||||
|
|
||||||
private mergeScopeNameWithScopes(scopeName: string, scope: Partial<Scope>): Scope {
|
|
||||||
return {
|
|
||||||
...scope,
|
|
||||||
metadata: {
|
|
||||||
name: scopeName,
|
|
||||||
...scope.metadata,
|
|
||||||
},
|
|
||||||
spec: {
|
|
||||||
filters: [],
|
|
||||||
title: scopeName,
|
|
||||||
type: '',
|
|
||||||
category: '',
|
|
||||||
description: '',
|
|
||||||
...scope.spec,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ScopesFiltersSceneRenderer({ model }: SceneComponentProps<ScopesFiltersScene>) {
|
|
||||||
const { nodes, expandedNodes, scopes } = model.useState();
|
|
||||||
const parentState = model.parent!.useState();
|
|
||||||
const isViewing = 'isViewing' in parentState ? !!parentState.isViewing : false;
|
|
||||||
|
|
||||||
const handleNodeExpand = (path: string[]) => model.expandNode(path);
|
|
||||||
const handleScopeToggle = (linkId: string) => model.toggleScope(linkId);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Toggletip
|
|
||||||
content={
|
|
||||||
<ScopesTreeLevel
|
|
||||||
isExpanded
|
|
||||||
path={[]}
|
|
||||||
nodes={nodes}
|
|
||||||
expandedNodes={expandedNodes}
|
|
||||||
scopes={scopes}
|
|
||||||
onNodeExpand={handleNodeExpand}
|
|
||||||
onScopeToggle={handleScopeToggle}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
footer={'Open advanced scope selector'}
|
|
||||||
closeButton={false}
|
|
||||||
>
|
|
||||||
<Input disabled={isViewing} readOnly value={scopes.map((scope) => scope.spec.title)} />
|
|
||||||
</Toggletip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ScopesTreeLevelProps {
|
|
||||||
isExpanded: boolean;
|
|
||||||
path: string[];
|
|
||||||
nodes: Record<string, Node>;
|
|
||||||
expandedNodes: string[];
|
|
||||||
scopes: Scope[];
|
|
||||||
onNodeExpand: (path: string[]) => void;
|
|
||||||
onScopeToggle: (linkId: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ScopesTreeLevel({
|
|
||||||
isExpanded,
|
|
||||||
path,
|
|
||||||
nodes,
|
|
||||||
expandedNodes,
|
|
||||||
scopes,
|
|
||||||
onNodeExpand,
|
|
||||||
onScopeToggle,
|
|
||||||
}: ScopesTreeLevelProps) {
|
|
||||||
const styles = useStyles2(getStyles);
|
|
||||||
|
|
||||||
if (!isExpanded) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div role="tree" className={path.length > 0 ? styles.innerLevelContainer : undefined}>
|
|
||||||
{Object.values(nodes).map((node) => {
|
|
||||||
const {
|
|
||||||
item: { nodeId, linkId },
|
|
||||||
isScope,
|
|
||||||
children,
|
|
||||||
} = node;
|
|
||||||
const nodePath = [...path, nodeId];
|
|
||||||
const isExpanded = expandedNodes.includes(nodeId);
|
|
||||||
const isSelected = isScope && !!scopes.find((scope) => scope.metadata.name === linkId);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={nodeId}
|
|
||||||
role="treeitem"
|
|
||||||
aria-selected={isExpanded}
|
|
||||||
tabIndex={0}
|
|
||||||
className={cx(styles.item, isScope && styles.itemScope)}
|
|
||||||
onClick={(evt) => {
|
|
||||||
evt.stopPropagation();
|
|
||||||
onNodeExpand(nodePath);
|
|
||||||
}}
|
|
||||||
onKeyDown={(evt) => {
|
|
||||||
evt.stopPropagation();
|
|
||||||
onNodeExpand(nodePath);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{!isScope ? (
|
|
||||||
<Icon className={styles.icon} name="folder" />
|
|
||||||
) : (
|
|
||||||
<Checkbox
|
|
||||||
className={styles.checkbox}
|
|
||||||
checked={isSelected}
|
|
||||||
onChange={(evt) => {
|
|
||||||
evt.stopPropagation();
|
|
||||||
|
|
||||||
if (linkId) {
|
|
||||||
onScopeToggle(linkId);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<span>{node.item.title}</span>
|
|
||||||
|
|
||||||
<ScopesTreeLevel
|
|
||||||
isExpanded={isExpanded}
|
|
||||||
path={nodePath}
|
|
||||||
nodes={children}
|
|
||||||
expandedNodes={expandedNodes}
|
|
||||||
scopes={scopes}
|
|
||||||
onNodeExpand={onNodeExpand}
|
|
||||||
onScopeToggle={onScopeToggle}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const getStyles = (theme: GrafanaTheme2) => {
|
|
||||||
return {
|
|
||||||
innerLevelContainer: css({
|
|
||||||
marginLeft: theme.spacing(2),
|
|
||||||
}),
|
|
||||||
item: css({
|
|
||||||
cursor: 'pointer',
|
|
||||||
margin: theme.spacing(1, 0),
|
|
||||||
}),
|
|
||||||
itemScope: css({
|
|
||||||
cursor: 'default',
|
|
||||||
}),
|
|
||||||
icon: css({
|
|
||||||
marginRight: theme.spacing(1),
|
|
||||||
}),
|
|
||||||
checkbox: css({
|
|
||||||
marginRight: theme.spacing(1),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
};
|
|
@ -1,414 +0,0 @@
|
|||||||
import { waitFor } from '@testing-library/react';
|
|
||||||
|
|
||||||
import { Scope, ScopeDashboardBindingSpec, ScopeTreeItemSpec } from '@grafana/data';
|
|
||||||
import { config } from '@grafana/runtime';
|
|
||||||
import {
|
|
||||||
behaviors,
|
|
||||||
sceneGraph,
|
|
||||||
SceneGridItem,
|
|
||||||
SceneGridLayout,
|
|
||||||
SceneQueryRunner,
|
|
||||||
SceneTimeRange,
|
|
||||||
VizPanel,
|
|
||||||
} from '@grafana/scenes';
|
|
||||||
|
|
||||||
import { DashboardControls } from './DashboardControls';
|
|
||||||
import { DashboardScene } from './DashboardScene';
|
|
||||||
import { ScopeDashboard, ScopesDashboardsScene } from './ScopesDashboardsScene';
|
|
||||||
import { ScopesFiltersScene } from './ScopesFiltersScene';
|
|
||||||
import { ScopesScene } from './ScopesScene';
|
|
||||||
|
|
||||||
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: '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;
|
|
||||||
|
|
||||||
const mocksScopeDashboardBindings: ScopeDashboardBindingSpec[] = [
|
|
||||||
{ dashboard: '1', scope: 'slothPictureFactory' },
|
|
||||||
{ dashboard: '2', scope: 'slothPictureFactory' },
|
|
||||||
{ dashboard: '3', scope: 'slothVoteTracker' },
|
|
||||||
{ dashboard: '4', scope: 'slothVoteTracker' },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const mocksNodes: ScopeTreeItemSpec[] = [
|
|
||||||
{
|
|
||||||
nodeId: 'applications',
|
|
||||||
nodeType: 'container',
|
|
||||||
title: 'Applications',
|
|
||||||
description: 'Application Scopes',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nodeId: 'clusters',
|
|
||||||
nodeType: 'container',
|
|
||||||
title: 'Clusters',
|
|
||||||
description: 'Cluster Scopes',
|
|
||||||
linkType: 'scope',
|
|
||||||
linkId: 'indexHelperCluster',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nodeId: 'applications-slothPictureFactory',
|
|
||||||
nodeType: 'leaf',
|
|
||||||
title: 'slothPictureFactory',
|
|
||||||
description: 'slothPictureFactory',
|
|
||||||
linkType: 'scope',
|
|
||||||
linkId: 'slothPictureFactory',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nodeId: 'applications-slothVoteTracker',
|
|
||||||
nodeType: 'leaf',
|
|
||||||
title: 'slothVoteTracker',
|
|
||||||
description: 'slothVoteTracker',
|
|
||||||
linkType: 'scope',
|
|
||||||
linkId: 'slothVoteTracker',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nodeId: 'applications.clusters',
|
|
||||||
nodeType: 'container',
|
|
||||||
title: 'Clusters',
|
|
||||||
description: 'Application/Clusters Scopes',
|
|
||||||
linkType: 'scope',
|
|
||||||
linkId: 'indexHelperCluster',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nodeId: 'applications.clusters-slothClusterNorth',
|
|
||||||
nodeType: 'leaf',
|
|
||||||
title: 'slothClusterNorth',
|
|
||||||
description: 'slothClusterNorth',
|
|
||||||
linkType: 'scope',
|
|
||||||
linkId: 'slothClusterNorth',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nodeId: 'applications.clusters-slothClusterSouth',
|
|
||||||
nodeType: 'leaf',
|
|
||||||
title: 'slothClusterSouth',
|
|
||||||
description: 'slothClusterSouth',
|
|
||||||
linkType: 'scope',
|
|
||||||
linkId: 'slothClusterSouth',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nodeId: 'clusters-slothClusterNorth',
|
|
||||||
nodeType: 'leaf',
|
|
||||||
title: 'slothClusterNorth',
|
|
||||||
description: 'slothClusterNorth',
|
|
||||||
linkType: 'scope',
|
|
||||||
linkId: 'slothClusterNorth',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nodeId: 'clusters-slothClusterSouth',
|
|
||||||
nodeType: 'leaf',
|
|
||||||
title: 'slothClusterSouth',
|
|
||||||
description: 'slothClusterSouth',
|
|
||||||
linkType: 'scope',
|
|
||||||
linkId: 'slothClusterSouth',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nodeId: 'clusters.applications',
|
|
||||||
nodeType: 'container',
|
|
||||||
title: 'Applications',
|
|
||||||
description: 'Clusters/Application Scopes',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nodeId: 'clusters.applications-slothPictureFactory',
|
|
||||||
nodeType: 'leaf',
|
|
||||||
title: 'slothPictureFactory',
|
|
||||||
description: 'slothPictureFactory',
|
|
||||||
linkType: 'scope',
|
|
||||||
linkId: 'slothPictureFactory',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nodeId: 'clusters.applications-slothVoteTracker',
|
|
||||||
nodeType: 'leaf',
|
|
||||||
title: 'slothVoteTracker',
|
|
||||||
description: 'slothVoteTracker',
|
|
||||||
linkType: 'scope',
|
|
||||||
linkId: 'slothVoteTracker',
|
|
||||||
},
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const getDashboardDetailsForUid = (uid: string) => ({
|
|
||||||
dashboard: {
|
|
||||||
title: `Dashboard ${uid}`,
|
|
||||||
uid,
|
|
||||||
},
|
|
||||||
meta: {
|
|
||||||
url: `/d/dashboard${uid}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const getDashboardScopeForUid = (uid: string) => ({
|
|
||||||
title: `Dashboard ${uid}`,
|
|
||||||
uid,
|
|
||||||
url: `/d/dashboard${uid}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
jest.mock('@grafana/runtime', () => ({
|
|
||||||
__esModule: true,
|
|
||||||
...jest.requireActual('@grafana/runtime'),
|
|
||||||
getBackendSrv: () => ({
|
|
||||||
get: jest.fn().mockImplementation((url: string) => {
|
|
||||||
const search = new URLSearchParams(url.split('?').pop() || '');
|
|
||||||
|
|
||||||
if (url.startsWith('/apis/scope.grafana.app/v0alpha1/namespaces/default/find')) {
|
|
||||||
const parent = search.get('parent')?.replace('parent=', '');
|
|
||||||
|
|
||||||
return {
|
|
||||||
items: mocksNodes.filter((node) => (parent ? node.nodeId.startsWith(parent) : !node.nodeId.includes('-'))),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
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/scopedashboardbindings')) {
|
|
||||||
const scope = search.get('fieldSelector')?.replace('spec.scope=', '') ?? '';
|
|
||||||
|
|
||||||
return {
|
|
||||||
items: mocksScopeDashboardBindings.filter((binding) => binding.scope === scope),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url.startsWith('/api/dashboards/uid/')) {
|
|
||||||
const uid = url.split('/').pop();
|
|
||||||
|
|
||||||
return uid ? getDashboardDetailsForUid(uid) : {};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {};
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe('ScopesScene', () => {
|
|
||||||
describe('Feature flag off', () => {
|
|
||||||
beforeAll(() => {
|
|
||||||
config.featureToggles.scopeFilters = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Does not initialize', () => {
|
|
||||||
const dashboardScene = buildTestScene();
|
|
||||||
dashboardScene.activate();
|
|
||||||
const scopesScene = dashboardScene.state.scopes;
|
|
||||||
|
|
||||||
expect(scopesScene).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Feature flag on', () => {
|
|
||||||
let scopesNames: string[];
|
|
||||||
let scopes: Scope[];
|
|
||||||
let scopeDashboardBindings: ScopeDashboardBindingSpec[][];
|
|
||||||
let dashboards: ScopeDashboard[][];
|
|
||||||
let dashboardScene: DashboardScene;
|
|
||||||
let scopesScene: ScopesScene;
|
|
||||||
let filtersScene: ScopesFiltersScene;
|
|
||||||
let dashboardsScene: ScopesDashboardsScene;
|
|
||||||
let fetchBaseNodesSpy: jest.SpyInstance;
|
|
||||||
let fetchScopesSpy: jest.SpyInstance;
|
|
||||||
let fetchDashboardsSpy: jest.SpyInstance;
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
config.featureToggles.scopeFilters = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
scopesNames = ['slothClusterNorth', 'slothClusterSouth'];
|
|
||||||
scopes = scopesNames.map((scopeName) => mocksScopes.find((scope) => scope.metadata.name === scopeName)!);
|
|
||||||
scopeDashboardBindings = scopesNames.map(
|
|
||||||
(scopeName) => mocksScopeDashboardBindings.filter((binding) => binding.scope === scopeName)!
|
|
||||||
);
|
|
||||||
dashboards = scopeDashboardBindings.map((bindings) =>
|
|
||||||
bindings.map((binding) => getDashboardScopeForUid(binding.dashboard))
|
|
||||||
);
|
|
||||||
dashboardScene = buildTestScene();
|
|
||||||
scopesScene = dashboardScene.state.scopes!;
|
|
||||||
filtersScene = scopesScene.state.filters;
|
|
||||||
dashboardsScene = scopesScene.state.dashboards;
|
|
||||||
fetchBaseNodesSpy = jest.spyOn(filtersScene!, 'fetchBaseNodes');
|
|
||||||
fetchScopesSpy = jest.spyOn(filtersScene!, 'fetchScopes');
|
|
||||||
fetchDashboardsSpy = jest.spyOn(dashboardsScene!, 'fetchDashboards');
|
|
||||||
dashboardScene.activate();
|
|
||||||
scopesScene.activate();
|
|
||||||
filtersScene.activate();
|
|
||||||
dashboardsScene.activate();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Initializes', () => {
|
|
||||||
expect(scopesScene).toBeInstanceOf(ScopesScene);
|
|
||||||
expect(filtersScene).toBeInstanceOf(ScopesFiltersScene);
|
|
||||||
expect(dashboardsScene).toBeInstanceOf(ScopesDashboardsScene);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Fetches nodes list', () => {
|
|
||||||
expect(fetchBaseNodesSpy).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Fetches scope details', () => {
|
|
||||||
filtersScene.toggleScope(scopesNames[0]);
|
|
||||||
waitFor(() => {
|
|
||||||
expect(fetchScopesSpy).toHaveBeenCalled();
|
|
||||||
expect(filtersScene.state.scopes).toEqual(scopes.filter((scope) => scope.metadata.name === scopesNames[0]));
|
|
||||||
});
|
|
||||||
|
|
||||||
filtersScene.toggleScope(scopesNames[1]);
|
|
||||||
waitFor(() => {
|
|
||||||
expect(fetchScopesSpy).toHaveBeenCalled();
|
|
||||||
expect(filtersScene.state.scopes).toEqual(scopes);
|
|
||||||
});
|
|
||||||
|
|
||||||
filtersScene.toggleScope(scopesNames[0]);
|
|
||||||
waitFor(() => {
|
|
||||||
expect(fetchScopesSpy).toHaveBeenCalled();
|
|
||||||
expect(filtersScene.state.scopes).toEqual(scopes.filter((scope) => scope.metadata.name === scopesNames[1]));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Fetches dashboards list', () => {
|
|
||||||
filtersScene.toggleScope(scopesNames[0]);
|
|
||||||
waitFor(() => {
|
|
||||||
expect(fetchDashboardsSpy).toHaveBeenCalled();
|
|
||||||
expect(dashboardsScene.state.dashboards).toEqual(dashboards[0]);
|
|
||||||
});
|
|
||||||
|
|
||||||
filtersScene.toggleScope(scopesNames[1]);
|
|
||||||
waitFor(() => {
|
|
||||||
expect(fetchDashboardsSpy).toHaveBeenCalled();
|
|
||||||
expect(dashboardsScene.state.dashboards).toEqual(dashboards.flat());
|
|
||||||
});
|
|
||||||
|
|
||||||
filtersScene.toggleScope(scopesNames[0]);
|
|
||||||
|
|
||||||
waitFor(() => {
|
|
||||||
expect(fetchDashboardsSpy).toHaveBeenCalled();
|
|
||||||
expect(dashboardsScene.state.dashboards).toEqual(dashboards[1]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Enriches data requests', () => {
|
|
||||||
filtersScene.toggleScope(scopesNames[0]);
|
|
||||||
waitFor(() => {
|
|
||||||
const queryRunner = sceneGraph.findObject(dashboardScene, (o) => o.state.key === 'data-query-runner')!;
|
|
||||||
expect(dashboardScene.enrichDataRequest(queryRunner).scopes).toEqual(
|
|
||||||
scopes.filter((scope) => scope.metadata.name === scopesNames[0])
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
filtersScene.toggleScope(scopesNames[1]);
|
|
||||||
waitFor(() => {
|
|
||||||
const queryRunner = sceneGraph.findObject(dashboardScene, (o) => o.state.key === 'data-query-runner')!;
|
|
||||||
expect(dashboardScene.enrichDataRequest(queryRunner).scopes).toEqual(scopes);
|
|
||||||
});
|
|
||||||
|
|
||||||
filtersScene.toggleScope(scopesNames[0]);
|
|
||||||
waitFor(() => {
|
|
||||||
const queryRunner = sceneGraph.findObject(dashboardScene, (o) => o.state.key === 'data-query-runner')!;
|
|
||||||
expect(dashboardScene.enrichDataRequest(queryRunner).scopes).toEqual(
|
|
||||||
scopes.filter((scope) => scope.metadata.name === scopesNames[1])
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Toggles expanded state', () => {
|
|
||||||
scopesScene.toggleIsExpanded();
|
|
||||||
|
|
||||||
expect(scopesScene.state.isExpanded).toEqual(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Enters view mode', () => {
|
|
||||||
dashboardScene.onEnterEditMode();
|
|
||||||
|
|
||||||
expect(scopesScene.state.isViewing).toEqual(true);
|
|
||||||
expect(scopesScene.state.isExpanded).toEqual(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Exits view mode', () => {
|
|
||||||
dashboardScene.onEnterEditMode();
|
|
||||||
dashboardScene.exitEditMode({ skipConfirm: true });
|
|
||||||
|
|
||||||
expect(scopesScene.state.isViewing).toEqual(false);
|
|
||||||
expect(scopesScene.state.isExpanded).toEqual(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
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({})],
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
@ -1512,6 +1512,31 @@
|
|||||||
},
|
},
|
||||||
"dismissable-button": "Close"
|
"dismissable-button": "Close"
|
||||||
},
|
},
|
||||||
|
"scopes": {
|
||||||
|
"advancedSelector": {
|
||||||
|
"apply": "Apply",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"title": "Select scopes"
|
||||||
|
},
|
||||||
|
"basicSelector": {
|
||||||
|
"openAdvanced": "Open advanced scope selector <1></1>",
|
||||||
|
"placeholder": "Select scopes...",
|
||||||
|
"removeAll": "Remove all scopes"
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"collapse": "Collapse scope filters",
|
||||||
|
"expand": "Expand scope filters"
|
||||||
|
},
|
||||||
|
"suggestedDashboards": {
|
||||||
|
"loading": "Loading dashboards",
|
||||||
|
"search": "Filter"
|
||||||
|
},
|
||||||
|
"tree": {
|
||||||
|
"collapse": "Collapse",
|
||||||
|
"expand": "Expand",
|
||||||
|
"search": "Filter"
|
||||||
|
}
|
||||||
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"actions": {
|
"actions": {
|
||||||
"include-panels": "Include panels",
|
"include-panels": "Include panels",
|
||||||
|
@ -1512,6 +1512,31 @@
|
|||||||
},
|
},
|
||||||
"dismissable-button": "Cľőşę"
|
"dismissable-button": "Cľőşę"
|
||||||
},
|
},
|
||||||
|
"scopes": {
|
||||||
|
"advancedSelector": {
|
||||||
|
"apply": "Åppľy",
|
||||||
|
"cancel": "Cäʼnčęľ",
|
||||||
|
"title": "Ŝęľęčŧ şčőpęş"
|
||||||
|
},
|
||||||
|
"basicSelector": {
|
||||||
|
"openAdvanced": "Øpęʼn äđväʼnčęđ şčőpę şęľęčŧőř <1></1>",
|
||||||
|
"placeholder": "Ŝęľęčŧ şčőpęş...",
|
||||||
|
"removeAll": "Ŗęmővę äľľ şčőpęş"
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"collapse": "Cőľľäpşę şčőpę ƒįľŧęřş",
|
||||||
|
"expand": "Ēχpäʼnđ şčőpę ƒįľŧęřş"
|
||||||
|
},
|
||||||
|
"suggestedDashboards": {
|
||||||
|
"loading": "Ŀőäđįʼnģ đäşĥþőäřđş",
|
||||||
|
"search": "Fįľŧęř"
|
||||||
|
},
|
||||||
|
"tree": {
|
||||||
|
"collapse": "Cőľľäpşę",
|
||||||
|
"expand": "Ēχpäʼnđ",
|
||||||
|
"search": "Fįľŧęř"
|
||||||
|
}
|
||||||
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"actions": {
|
"actions": {
|
||||||
"include-panels": "Ĩʼnčľūđę päʼnęľş",
|
"include-panels": "Ĩʼnčľūđę päʼnęľş",
|
||||||
|
Loading…
Reference in New Issue
Block a user