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 {
|
||||
dashboard: string;
|
||||
dashboardTitle: 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 const scopeFilterOperatorMap: Record<string, ScopeFilterOperator> = {
|
||||
@ -34,15 +43,23 @@ export interface Scope {
|
||||
spec: ScopeSpec;
|
||||
}
|
||||
|
||||
export type ScopeTreeItemNodeType = 'container' | 'leaf';
|
||||
export type ScopeTreeItemLinkType = 'scope';
|
||||
export type ScopeNodeNodeType = 'container' | 'leaf';
|
||||
export type ScopeNodeLinkType = 'scope';
|
||||
|
||||
export interface ScopeTreeItemSpec {
|
||||
nodeId: string;
|
||||
nodeType: ScopeTreeItemNodeType;
|
||||
export interface ScopeNodeSpec {
|
||||
nodeType: ScopeNodeNodeType;
|
||||
title: string;
|
||||
|
||||
description?: string;
|
||||
disableMultiSelect?: boolean;
|
||||
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 { LibraryVizPanel } from './LibraryVizPanel';
|
||||
import { RowRepeaterBehavior } from './RowRepeaterBehavior';
|
||||
import { ScopesScene } from './ScopesScene';
|
||||
import { ScopesScene } from './Scopes/ScopesScene';
|
||||
import { ViewPanelScene } from './ViewPanelScene';
|
||||
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 { SceneComponentProps, sceneGraph, SceneObjectBase, SceneObjectState } from '@grafana/scenes';
|
||||
import { IconButton, useStyles2 } from '@grafana/ui';
|
||||
import { t } from 'app/core/internationalization';
|
||||
|
||||
import { ScopesDashboardsScene } from './ScopesDashboardsScene';
|
||||
import { ScopesFiltersScene } from './ScopesFiltersScene';
|
||||
@ -27,44 +28,52 @@ export class ScopesScene extends SceneObjectBase<ScopesSceneState> {
|
||||
});
|
||||
|
||||
this.addActivationHandler(() => {
|
||||
this.state.filters.fetchBaseNodes();
|
||||
this._subs.add(
|
||||
this.state.filters.subscribeToState((newState, prevState) => {
|
||||
if (newState.scopes !== prevState.scopes) {
|
||||
if (this.state.isExpanded) {
|
||||
this.state.dashboards.fetchDashboards(newState.scopes);
|
||||
}
|
||||
|
||||
const filtersValueSubscription = this.state.filters.subscribeToState((newState, prevState) => {
|
||||
if (newState.scopes !== prevState.scopes) {
|
||||
this.state.dashboards.fetchDashboards(newState.scopes);
|
||||
sceneGraph.getTimeRange(this.parent!).onRefresh();
|
||||
}
|
||||
});
|
||||
|
||||
const dashboardEditModeSubscription = this.parent?.subscribeToState((newState) => {
|
||||
const isEditing = 'isEditing' in newState ? !!newState.isEditing : false;
|
||||
|
||||
if (isEditing !== this.state.isViewing) {
|
||||
if (isEditing) {
|
||||
this.enterViewMode();
|
||||
} else {
|
||||
this.exitViewMode();
|
||||
sceneGraph.getTimeRange(this.parent!).onRefresh();
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
return () => {
|
||||
filtersValueSubscription.unsubscribe();
|
||||
dashboardEditModeSubscription?.unsubscribe();
|
||||
};
|
||||
this._subs.add(
|
||||
this.parent?.subscribeToState((newState) => {
|
||||
const isEditing = 'isEditing' in newState ? !!newState.isEditing : false;
|
||||
|
||||
if (isEditing !== this.state.isViewing) {
|
||||
if (isEditing) {
|
||||
this.enterViewMode();
|
||||
} else {
|
||||
this.exitViewMode();
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
public getSelectedScopes() {
|
||||
return this.state.filters.state.scopes;
|
||||
return this.state.filters.getSelectedScopes();
|
||||
}
|
||||
|
||||
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() {
|
||||
this.setState({ isExpanded: false, isViewing: true });
|
||||
|
||||
this.state.filters.enterViewMode();
|
||||
}
|
||||
|
||||
private exitViewMode() {
|
||||
@ -82,17 +91,21 @@ export function ScopesSceneRenderer({ model }: SceneComponentProps<ScopesScene>)
|
||||
{!isViewing && (
|
||||
<IconButton
|
||||
name="arrow-to-right"
|
||||
aria-label={isExpanded ? 'Collapse scope filters' : 'Expand scope filters'}
|
||||
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()}
|
||||
/>
|
||||
)}
|
||||
<filters.Component model={filters} />
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className={styles.dashboardsContainer} data-testid="scopes-scene-dashboards-container">
|
||||
{isExpanded && !isViewing && (
|
||||
<div className={styles.dashboardsContainer} data-testid="scopes-dashboards-container">
|
||||
<dashboards.Component model={dashboards} />
|
||||
</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"
|
||||
},
|
||||
"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": {
|
||||
"actions": {
|
||||
"include-panels": "Include panels",
|
||||
|
@ -1512,6 +1512,31 @@
|
||||
},
|
||||
"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": {
|
||||
"actions": {
|
||||
"include-panels": "Ĩʼnčľūđę päʼnęľş",
|
||||
|
Loading…
Reference in New Issue
Block a user