Scopes: UI Improvements (#88026)

This commit is contained in:
Bogdan Matei 2024-06-06 17:00:56 +03:00 committed by GitHub
parent d88f2734ae
commit 8d36949f61
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 1620 additions and 925 deletions

View File

@ -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;
} }

View File

@ -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';

View File

@ -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,
},
}),
};
};

View File

@ -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),
}),
};
};

View File

@ -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%',
}),
};
};

View File

@ -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} />
</>
);
}

View File

@ -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')
);
});
});
});
});
});

View File

@ -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>
)} )}

View File

@ -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),
}),
};
};

View 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 [];
}
}

View 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} />);
}

View 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>;

View File

@ -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,
},
}),
};
};

View File

@ -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),
}),
};
};

View File

@ -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,
});
}

View File

@ -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",

View File

@ -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ęľş",