Implement Scopes UI (#82920)

This commit is contained in:
Bogdan Matei
2024-03-21 13:01:47 +02:00
committed by GitHub
parent b41ad688f2
commit b23541c298
13 changed files with 847 additions and 30 deletions
@@ -228,6 +228,8 @@ export interface GrafanaConfig {
rootFolderUID?: string;
localFileSystemAvailable?: boolean;
cloudMigrationIsTarget?: boolean;
listDashboardScopesEndpoint?: string;
listScopesEndpoint?: string;
// The namespace to use for kubernetes apiserver requests
namespace: string;
@@ -14,7 +14,7 @@ import { DataQuery } from './query';
import { RawTimeRange, TimeRange } from './time';
import { CustomVariableSupport, DataSourceVariableSupport, StandardVariableSupport } from './variables';
import { AdHocVariableFilter, DataSourceRef, WithAccessControlMetadata } from '.';
import { AdHocVariableFilter, DataSourceRef, Scope, WithAccessControlMetadata } from '.';
export interface DataSourcePluginOptionsEditorProps<
JSONData extends DataSourceJsonData = DataSourceJsonData,
@@ -561,6 +561,8 @@ export interface DataQueryRequest<TQuery extends DataQuery = DataQuery> {
// Used to correlate multiple related requests
queryGroupId?: string;
scope?: Scope | undefined;
}
export interface DataQueryTimings {
+1
View File
@@ -66,3 +66,4 @@ export {
type PluginExtensionCommandPaletteContext,
type PluginExtensionOpenModalOptions,
} from './pluginExtensions';
export * from './scopes';
+20
View File
@@ -0,0 +1,20 @@
export interface ScopeDashboard {
uid: string;
title: string;
url: string;
}
export interface ScopeFilter {
key: string;
value: string;
operator: string;
}
export interface Scope {
uid: string;
title: string;
type: string;
description: string;
category: string;
filters: ScopeFilter[];
}
@@ -1,4 +1,4 @@
import { css } from '@emotion/css';
import { css, cx } from '@emotion/css';
import React, { useEffect, useState } from 'react';
import { GrafanaTheme2, urlUtil } from '@grafana/data';
@@ -42,7 +42,7 @@ interface RendererProps extends EmbeddedDashboardProps {
function EmbeddedDashboardRenderer({ model, initialState, onStateChange }: RendererProps) {
const [isActive, setIsActive] = useState(false);
const { controls, body } = model.useState();
const { controls, body, scopes } = model.useState();
const styles = useStyles2(getStyles);
useEffect(() => {
@@ -64,8 +64,15 @@ function EmbeddedDashboardRenderer({ model, initialState, onStateChange }: Rende
}
return (
<div className={styles.canvas}>
{controls && <controls.Component model={controls} />}
<div
className={cx(styles.canvas, controls && !scopes && styles.canvasWithControls, scopes && styles.canvasWithScopes)}
>
{scopes && <scopes.Component model={scopes} />}
{controls && (
<div className={cx(styles.controlsWrapper, scopes && styles.controlsWrapperWithScopes)}>
<controls.Component model={controls} />
</div>
)}
<div className={styles.body}>
<body.Component model={body} />
</div>
@@ -100,26 +107,44 @@ function getStyles(theme: GrafanaTheme2) {
return {
canvas: css({
label: 'canvas-content',
display: 'flex',
flexDirection: 'column',
display: 'grid',
gridTemplateAreas: `
"panels"`,
gridTemplateColumns: `1fr`,
gridTemplateRows: '1fr',
flexBasis: '100%',
flexGrow: 1,
}),
canvasWithControls: css({
gridTemplateAreas: `
"controls"
"panels"`,
gridTemplateRows: 'auto 1fr',
}),
canvasWithScopes: css({
gridTemplateAreas: `
"scopes controls"
"panels panels"`,
gridTemplateColumns: `${theme.spacing(32)} 1fr`,
gridTemplateRows: 'auto 1fr',
}),
body: css({
label: 'body',
flexGrow: 1,
display: 'flex',
gap: '8px',
gridArea: 'panels',
marginBottom: theme.spacing(2),
}),
controls: css({
controlsWrapper: css({
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
gap: theme.spacing(1),
top: 0,
zIndex: theme.zIndex.navbarFixed,
padding: theme.spacing(0, 0, 2, 0),
flexDirection: 'column',
flexGrow: 0,
gridArea: 'controls',
padding: theme.spacing(2, 0, 2, 2),
}),
controlsWrapperWithScopes: css({
padding: theme.spacing(2, 0),
}),
};
}
@@ -62,7 +62,7 @@ function VizAndDataPane({ model }: SceneComponentProps<PanelEditor>) {
const { vizManager, dataPane, showLibraryPanelSaveModal, showLibraryPanelUnlinkModal } = model.useState();
const { sourcePanel } = vizManager.useState();
const libraryPanel = getLibraryPanel(sourcePanel.resolve());
const { controls } = dashboard.useState();
const { controls, scopes } = dashboard.useState();
const styles = useStyles2(getStyles);
const { containerProps, primaryProps, secondaryProps, splitterProps, splitterState, onToggleCollapse } =
@@ -75,13 +75,26 @@ function VizAndDataPane({ model }: SceneComponentProps<PanelEditor>) {
},
});
containerProps.className = cx(containerProps.className, styles.container);
if (!dataPane) {
primaryProps.style.flexGrow = 1;
}
return (
<>
<div className={styles.controlsWrapper}>{controls && <controls.Component model={controls} />}</div>
<div
className={cx(
styles.pageContainer,
controls && !scopes && styles.pageContainerWithControls,
scopes && styles.pageContainerWithScopes
)}
>
{scopes && <scopes.Component model={scopes} />}
{controls && (
<div className={cx(styles.controlsWrapper, scopes && styles.controlsWrapperWithScopes)}>
<controls.Component model={controls} />
</div>
)}
<div {...containerProps}>
<div {...primaryProps}>
<vizManager.Component model={vizManager} />
@@ -123,12 +136,37 @@ function VizAndDataPane({ model }: SceneComponentProps<PanelEditor>) {
</>
)}
</div>
</>
</div>
);
}
function getStyles(theme: GrafanaTheme2) {
return {
pageContainer: css({
display: 'grid',
gridTemplateAreas: `
"panels"`,
gridTemplateColumns: `1fr`,
gridTemplateRows: '1fr',
height: '100%',
}),
pageContainerWithControls: css({
gridTemplateAreas: `
"controls"
"panels"`,
gridTemplateRows: 'auto 1fr',
}),
pageContainerWithScopes: css({
gridTemplateAreas: `
"scopes controls"
"panels panels"`,
gridTemplateColumns: `${theme.spacing(32)} 1fr`,
gridTemplateRows: 'auto 1fr',
}),
container: css({
gridArea: 'panels',
height: '100%',
}),
canvasContent: css({
label: 'canvas-content',
display: 'flex',
@@ -172,7 +210,11 @@ function getStyles(theme: GrafanaTheme2) {
display: 'flex',
flexDirection: 'column',
flexGrow: 0,
paddingLeft: theme.spacing(2),
gridArea: 'controls',
padding: theme.spacing(2, 0, 2, 2),
}),
controlsWrapperWithScopes: css({
padding: theme.spacing(2, 0),
}),
openDataPaneButton: css({
width: theme.spacing(8),
@@ -70,12 +70,14 @@ function getStyles(theme: GrafanaTheme2) {
controls: css({
display: 'flex',
alignItems: 'flex-start',
flex: '100%',
gap: theme.spacing(1),
flexDirection: 'row',
flexWrap: 'nowrap',
position: 'sticky',
top: 0,
background: theme.colors.background.canvas,
zIndex: theme.zIndex.navbarFixed,
padding: theme.spacing(2, 0),
width: '100%',
marginLeft: 'auto',
[theme.breakpoints.down('sm')]: {
@@ -1,7 +1,7 @@
import * as H from 'history';
import { AppEvents, CoreApp, DataQueryRequest, NavIndex, NavModelItem, locationUtil } from '@grafana/data';
import { locationService } from '@grafana/runtime';
import { config, locationService } from '@grafana/runtime';
import {
getUrlSyncManager,
SceneFlexLayout,
@@ -32,7 +32,6 @@ import { ShowConfirmModalEvent } from 'app/types/events';
import { PanelEditor } from '../panel-edit/PanelEditor';
import { DashboardSceneChangeTracker } from '../saving/DashboardSceneChangeTracker';
import { SaveDashboardDrawer } from '../saving/SaveDashboardDrawer';
import { DashboardSceneRenderer } from '../scene/DashboardSceneRenderer';
import {
buildGridItemForLibPanel,
buildGridItemForPanel,
@@ -60,9 +59,11 @@ import {
import { AddLibraryPanelWidget } from './AddLibraryPanelWidget';
import { DashboardControls } from './DashboardControls';
import { DashboardSceneRenderer } from './DashboardSceneRenderer';
import { DashboardSceneUrlSync } from './DashboardSceneUrlSync';
import { LibraryVizPanel } from './LibraryVizPanel';
import { PanelRepeaterGridItem } from './PanelRepeaterGridItem';
import { ScopesScene } from './ScopesScene';
import { ViewPanelScene } from './ViewPanelScene';
import { setupKeyboardShortcuts } from './keyboardShortcuts';
@@ -111,6 +112,8 @@ export interface DashboardSceneState extends SceneObjectState {
hasCopiedPanel?: boolean;
/** The dashboard doesn't have panels */
isEmpty?: boolean;
/** Scene object that handles the scopes selector */
scopes?: ScopesScene;
}
export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
@@ -151,6 +154,7 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
body: state.body ?? new SceneFlexLayout({ children: [] }),
links: state.links ?? [],
hasCopiedPanel: store.exists(LS_PANEL_COPY_KEY),
scopes: state.uid && config.featureToggles.scopeFilters ? new ScopesScene() : undefined,
...state,
});
@@ -783,6 +787,7 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
dashboardUID: this.state.uid,
panelId,
panelPluginId: panel?.state.pluginId,
scope: this.state.scopes?.state.filters.getSelectedScope(),
};
}
@@ -14,7 +14,8 @@ import { DashboardScene } from './DashboardScene';
import { NavToolbarActions } from './NavToolbarActions';
export function DashboardSceneRenderer({ model }: SceneComponentProps<DashboardScene>) {
const { controls, overlay, editview, editPanel, isEmpty } = model.useState();
const { controls, overlay, editview, editPanel, isEmpty, scopes } = model.useState();
const { isExpanded: isScopesExpanded } = scopes?.useState() ?? {};
const styles = useStyles2(getStyles);
const location = useLocation();
const navIndex = useSelector((state) => state.navIndex);
@@ -43,13 +44,27 @@ export function DashboardSceneRenderer({ model }: SceneComponentProps<DashboardS
<Page navModel={navModel} pageNav={pageNav} layout={PageLayoutType.Custom}>
{editPanel && <editPanel.Component model={editPanel} />}
{!editPanel && (
<CustomScrollbar autoHeightMin={'100%'}>
<div className={styles.canvasContent}>
<NavToolbarActions dashboard={model} />
{controls && <controls.Component model={controls} />}
{isEmpty ? emptyState : withPanels}
</div>
</CustomScrollbar>
<div
className={cx(
styles.pageContainer,
controls && !scopes && styles.pageContainerWithControls,
scopes && styles.pageContainerWithScopes,
scopes && isScopesExpanded && styles.pageContainerWithScopesExpanded
)}
>
{scopes && <scopes.Component model={scopes} />}
<NavToolbarActions dashboard={model} />
{controls && (
<div
className={cx(styles.controlsWrapper, scopes && !isScopesExpanded && styles.controlsWrapperWithScopes)}
>
<controls.Component model={controls} />
</div>
)}
<CustomScrollbar autoHeightMin={'100%'} className={styles.scrollbarContainer}>
<div className={styles.canvasContent}>{isEmpty ? emptyState : withPanels}</div>
</CustomScrollbar>
</div>
)}
{overlay && <overlay.Component model={overlay} />}
</Page>
@@ -58,6 +73,45 @@ export function DashboardSceneRenderer({ model }: SceneComponentProps<DashboardS
function getStyles(theme: GrafanaTheme2) {
return {
pageContainer: css({
display: 'grid',
gridTemplateAreas: `
"panels"`,
gridTemplateColumns: `1fr`,
gridTemplateRows: '1fr',
height: '100%',
}),
pageContainerWithControls: css({
gridTemplateAreas: `
"controls"
"panels"`,
gridTemplateRows: 'auto 1fr',
}),
pageContainerWithScopes: css({
gridTemplateAreas: `
"scopes controls"
"panels panels"`,
gridTemplateColumns: `${theme.spacing(32)} 1fr`,
gridTemplateRows: 'auto 1fr',
}),
pageContainerWithScopesExpanded: css({
gridTemplateAreas: `
"scopes controls"
"scopes panels"`,
}),
scrollbarContainer: css({
gridArea: 'panels',
}),
controlsWrapper: css({
display: 'flex',
flexDirection: 'column',
flexGrow: 0,
gridArea: 'controls',
padding: theme.spacing(2),
}),
controlsWrapperWithScopes: css({
padding: theme.spacing(2, 2, 2, 0),
}),
canvasContent: css({
label: 'canvas-content',
display: 'flex',
@@ -0,0 +1,151 @@
import { css } from '@emotion/css';
import React from 'react';
import { Link } from 'react-router-dom';
import { AppEvents, GrafanaTheme2, ScopeDashboard } from '@grafana/data';
import { config, getAppEvents, getBackendSrv, locationService } from '@grafana/runtime';
import { SceneComponentProps, SceneObjectBase, SceneObjectState } from '@grafana/scenes';
import { CustomScrollbar, Icon, Input, useStyles2 } from '@grafana/ui';
export interface ScopesDashboardsSceneState extends SceneObjectState {
dashboards: ScopeDashboard[];
filteredDashboards: ScopeDashboard[];
isLoading: boolean;
searchQuery: string;
}
export class ScopesDashboardsScene extends SceneObjectBase<ScopesDashboardsSceneState> {
static Component = ScopesDashboardsSceneRenderer;
private _url =
config.bootData.settings.listDashboardScopesEndpoint || '/apis/scope.grafana.app/v0alpha1/scopedashboards';
constructor() {
super({
dashboards: [],
filteredDashboards: [],
isLoading: false,
searchQuery: '',
});
}
public async fetchDashboards(scope: string | undefined) {
if (!scope) {
return this.setState({ dashboards: [], filteredDashboards: [], isLoading: false });
}
this.setState({ isLoading: true });
const dashboardUids = await this.fetchDashboardsUids(scope);
const dashboards = await this.fetchDashboardsDetails(dashboardUids);
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 getBackendSrv().get<{
items: Array<{ spec: { dashboardUids: null | string[]; scopeUid: string } }>;
}>(this._url, { scope });
return (
response.items.find((item) => !!item.spec.dashboardUids && item.spec.scopeUid === scope)?.spec.dashboardUids ??
[]
);
} 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);
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={{ pathname: dashboard.url, search: locationService.getLocation().search }}>
{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,
},
}),
};
};
@@ -0,0 +1,120 @@
import React from 'react';
import { AppEvents, Scope, SelectableValue } from '@grafana/data';
import { config, getAppEvents, getBackendSrv } from '@grafana/runtime';
import {
SceneComponentProps,
SceneObjectBase,
SceneObjectState,
SceneObjectUrlSyncConfig,
SceneObjectUrlValues,
} from '@grafana/scenes';
import { Select } from '@grafana/ui';
export interface ScopesFiltersSceneState extends SceneObjectState {
isLoading: boolean;
pendingValue: string | undefined;
scopes: Scope[];
value: string | undefined;
}
export class ScopesFiltersScene extends SceneObjectBase<ScopesFiltersSceneState> {
static Component = ScopesFiltersSceneRenderer;
protected _urlSync = new SceneObjectUrlSyncConfig(this, { keys: ['scope'] });
private _url = config.bootData.settings.listScopesEndpoint || '/apis/scope.grafana.app/v0alpha1/scopes';
constructor() {
super({
isLoading: true,
pendingValue: undefined,
scopes: [],
value: undefined,
});
}
getUrlState() {
return { scope: this.state.value };
}
updateFromUrl(values: SceneObjectUrlValues) {
const scope = values.scope ?? undefined;
this.setScope(Array.isArray(scope) ? scope[0] : scope);
}
public getSelectedScope(): Scope | undefined {
return this.state.scopes.find((scope) => scope.uid === this.state.value);
}
public setScope(newScope: string | undefined) {
if (this.state.isLoading) {
return this.setState({ pendingValue: newScope });
}
if (!this.state.scopes.find((scope) => scope.uid === newScope)) {
newScope = undefined;
}
this.setState({ value: newScope });
}
public async fetchScopes() {
this.setState({ isLoading: true });
try {
const response = await getBackendSrv().get<{
items: Array<{ metadata: { uid: string }; spec: Omit<Scope, 'uid'> }>;
}>(this._url);
this.setScopesAfterFetch(
response.items.map(({ metadata: { uid }, spec }) => ({
uid,
...spec,
}))
);
} catch (err) {
getAppEvents().publish({
type: AppEvents.alertError.name,
payload: ['Failed to fetch scopes'],
});
this.setScopesAfterFetch([]);
} finally {
this.setState({ isLoading: false });
}
}
private setScopesAfterFetch(scopes: Scope[]) {
let value = this.state.pendingValue ?? this.state.value;
if (!scopes.find((scope) => scope.uid === value)) {
value = undefined;
}
this.setState({ scopes, pendingValue: undefined, value });
}
}
export function ScopesFiltersSceneRenderer({ model }: SceneComponentProps<ScopesFiltersScene>) {
const { scopes, isLoading, value } = model.useState();
const parentState = model.parent!.useState();
const isViewing = 'isViewing' in parentState ? !!parentState.isViewing : false;
const options: Array<SelectableValue<string>> = scopes.map(({ uid, title, category }) => ({
label: title,
value: uid,
description: category,
}));
return (
<Select
isClearable
isLoading={isLoading}
disabled={isViewing}
options={options}
value={value}
onChange={(selectableValue) => model.setScope(selectableValue?.value ?? undefined)}
/>
);
}
@@ -0,0 +1,261 @@
import { waitFor } from '@testing-library/react';
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 { ScopesDashboardsScene } from './ScopesDashboardsScene';
import { ScopesFiltersScene } from './ScopesFiltersScene';
import { ScopesScene } from './ScopesScene';
const dashboardsMocks = {
dashboard1: {
uid: 'dashboard1',
title: 'Dashboard 1',
url: '/d/dashboard1',
},
dashboard2: {
uid: 'dashboard2',
title: 'Dashboard 2',
url: '/d/dashboard2',
},
dashboard3: {
uid: 'dashboard3',
title: 'Dashboard 3',
url: '/d/dashboard3',
},
};
const scopesMocks = {
scope1: {
uid: 'scope1',
title: 'Scope 1',
type: 'Type 1',
description: 'Description 1',
category: 'Category 1',
filters: [
{ key: 'a-key', operator: '=', value: 'a-value' },
{ key: 'b-key', operator: '!=', value: 'b-value' },
],
dashboards: [dashboardsMocks.dashboard1, dashboardsMocks.dashboard2, dashboardsMocks.dashboard3],
},
scope2: {
uid: 'scope2',
title: 'Scope 2',
type: 'Type 2',
description: 'Description 2',
category: 'Category 2',
filters: [{ key: 'c-key', operator: '!=', value: 'c-value' }],
dashboards: [dashboardsMocks.dashboard3],
},
scope3: {
uid: 'scope3',
title: 'Scope 3',
type: 'Type 1',
description: 'Description 3',
category: 'Category 1',
filters: [{ key: 'd-key', operator: '=', value: 'd-value' }],
dashboards: [dashboardsMocks.dashboard1, dashboardsMocks.dashboard2],
},
};
jest.mock('@grafana/runtime', () => ({
__esModule: true,
...jest.requireActual('@grafana/runtime'),
getBackendSrv: () => ({
get: jest.fn().mockImplementation((url: string) => {
if (url === '/apis/scope.grafana.app/v0alpha1/scopes') {
return {
items: Object.values(scopesMocks).map((scope) => ({
metadata: { uid: scope.uid },
spec: {
title: scope.title,
type: scope.type,
description: scope.description,
category: scope.category,
filters: scope.filters,
},
})),
};
}
if (url === '/apis/scope.grafana.app/v0alpha1/scopedashboards') {
return {
items: Object.values(scopesMocks).map((scope) => ({
spec: {
dashboardUids: scope.dashboards.map((dashboard) => dashboard.uid),
scopeUid: scope.uid,
},
})),
};
}
if (url.startsWith('/api/dashboards/uid/')) {
const uid = url.split('/').pop();
if (!uid) {
return {};
}
const dashboard = Object.values(dashboardsMocks).find((dashboard) => dashboard.uid === uid);
if (!dashboard) {
return {};
}
return {
dashboard: {
title: dashboard.title,
uid,
},
meta: {
url: dashboard.url,
},
};
}
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 dashboardScene: DashboardScene;
let scopesScene: ScopesScene;
let filtersScene: ScopesFiltersScene;
let dashboardsScene: ScopesDashboardsScene;
let fetchScopesSpy: jest.SpyInstance;
let fetchDashboardsSpy: jest.SpyInstance;
beforeAll(() => {
config.featureToggles.scopeFilters = true;
});
beforeEach(() => {
dashboardScene = buildTestScene();
scopesScene = dashboardScene.state.scopes!;
filtersScene = scopesScene.state.filters;
dashboardsScene = scopesScene.state.dashboards;
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 scopes list', async () => {
expect(fetchScopesSpy).toHaveBeenCalled();
});
it('Fetches dashboards list', () => {
filtersScene.setScope(scopesMocks.scope1.uid);
waitFor(() => {
expect(fetchDashboardsSpy).toHaveBeenCalled();
expect(dashboardsScene.state.dashboards).toEqual(scopesMocks.scope1.dashboards);
});
filtersScene.setScope(scopesMocks.scope2.uid);
waitFor(() => {
expect(fetchDashboardsSpy).toHaveBeenCalled();
expect(dashboardsScene.state.dashboards).toEqual(scopesMocks.scope2.dashboards);
});
});
it('Enriches data requests', () => {
const { dashboards: _dashboards, ...scope1 } = scopesMocks.scope1;
filtersScene.setScope(scope1.uid);
const queryRunner = sceneGraph.findObject(dashboardScene, (o) => o.state.key === 'data-query-runner')!;
expect(dashboardScene.enrichDataRequest(queryRunner).scope).toEqual(scope1);
});
it('Toggles expanded state', async () => {
scopesScene.toggleIsExpanded();
expect(scopesScene.state.isExpanded).toEqual(true);
});
it('Enters view mode', async () => {
dashboardScene.onEnterEditMode();
expect(scopesScene.state.isViewing).toEqual(true);
expect(scopesScene.state.isExpanded).toEqual(false);
});
it('Exits view mode', async () => {
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,
});
}
@@ -0,0 +1,132 @@
import { css, cx } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { SceneComponentProps, sceneGraph, SceneObjectBase, SceneObjectState } from '@grafana/scenes';
import { IconButton, useStyles2 } from '@grafana/ui';
import { ScopesDashboardsScene } from './ScopesDashboardsScene';
import { ScopesFiltersScene } from './ScopesFiltersScene';
export interface ScopesSceneState extends SceneObjectState {
dashboards: ScopesDashboardsScene;
filters: ScopesFiltersScene;
isExpanded: boolean;
isViewing: boolean;
}
export class ScopesScene extends SceneObjectBase<ScopesSceneState> {
static Component = ScopesSceneRenderer;
constructor() {
super({
dashboards: new ScopesDashboardsScene(),
filters: new ScopesFiltersScene(),
isExpanded: false,
isViewing: false,
});
this.addActivationHandler(() => {
this.state.filters.fetchScopes();
const filtersValueSubscription = this.state.filters.subscribeToState((newState, prevState) => {
if (newState.value !== prevState.value) {
this.state.dashboards.fetchDashboards(newState.value);
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();
}
}
});
return () => {
filtersValueSubscription.unsubscribe();
dashboardEditModeSubscription?.unsubscribe();
};
});
}
public toggleIsExpanded() {
this.setState({ isExpanded: !this.state.isExpanded });
}
private enterViewMode() {
this.setState({ isExpanded: false, isViewing: true });
}
private exitViewMode() {
this.setState({ isViewing: false });
}
}
export function ScopesSceneRenderer({ model }: SceneComponentProps<ScopesScene>) {
const { filters, dashboards, isExpanded, isViewing } = model.useState();
const styles = useStyles2(getStyles);
return (
<div className={cx(styles.container, isExpanded && styles.containerExpanded)}>
<div className={cx(styles.filtersContainer, isExpanded && styles.filtersContainerExpanded)}>
{!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"
onClick={() => model.toggleIsExpanded()}
/>
)}
<filters.Component model={filters} />
</div>
{isExpanded && (
<div className={styles.dashboardsContainer} data-testid="scopes-scene-dashboards-container">
<dashboards.Component model={dashboards} />
</div>
)}
</div>
);
}
const getStyles = (theme: GrafanaTheme2) => {
return {
container: css({
display: 'flex',
flexDirection: 'column',
gridArea: 'scopes',
}),
containerExpanded: css({
backgroundColor: theme.colors.background.primary,
height: '100%',
}),
filtersContainer: css({
display: 'flex',
flex: '0 1 auto',
flexDirection: 'row',
padding: theme.spacing(2, 2, 2, 2),
}),
filtersContainerExpanded: css({
borderBottom: `1px solid ${theme.colors.border.weak}`,
padding: theme.spacing(2),
}),
iconNotExpanded: css({
transform: 'scaleX(-1)',
}),
dashboardsContainer: css({
display: 'flex',
flex: '1 1 auto',
flexDirection: 'column',
gap: theme.spacing(3),
overflow: 'hidden',
padding: theme.spacing(2),
}),
};
};