DashboardGrid: Add support to filter panels using variable (#77112)

* DashboardGrid panel filter

* Missed segment and changes per PR discussion

* Hide feature flag from docs

---------

Co-authored-by: Dominik Prokop <dominik.prokop@grafana.com>
This commit is contained in:
Jacob Zelek 2023-11-03 05:15:54 -07:00 committed by GitHub
parent 577b3f2fb2
commit 6bf4d0cbc6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 237 additions and 19 deletions

View File

@ -3292,7 +3292,7 @@ exports[`better eslint`] = {
[0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "4"]
],
"public/app/features/dashboard/dashgrid/DashboardGrid.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"public/app/features/dashboard/dashgrid/DashboardPanel.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]

View File

@ -157,4 +157,5 @@ export interface FeatureToggles {
annotationPermissionUpdate?: boolean;
extractFieldsNameDeduplication?: boolean;
dashboardSceneForViewers?: boolean;
panelFilterVariable?: boolean;
}

View File

@ -974,5 +974,13 @@ var (
FrontendOnly: true,
Owner: grafanaDashboardsSquad,
},
{
Name: "panelFilterVariable",
Description: "Enables use of the `systemPanelFilterVar` variable to filter panels in a dashboard",
Stage: FeatureStageExperimental,
FrontendOnly: true,
Owner: grafanaDashboardsSquad,
HideFromDocs: true,
},
}
)

View File

@ -138,3 +138,4 @@ alertmanagerRemoteOnly,experimental,@grafana/alerting-squad,false,false,false,fa
annotationPermissionUpdate,experimental,@grafana/grafana-authnz-team,false,false,false,false
extractFieldsNameDeduplication,experimental,@grafana/grafana-bi-squad,false,false,false,true
dashboardSceneForViewers,experimental,@grafana/dashboards-squad,false,false,false,true
panelFilterVariable,experimental,@grafana/dashboards-squad,false,false,false,true

1 Name Stage Owner requiresDevMode RequiresLicense RequiresRestart FrontendOnly
138 annotationPermissionUpdate experimental @grafana/grafana-authnz-team false false false false
139 extractFieldsNameDeduplication experimental @grafana/grafana-bi-squad false false false true
140 dashboardSceneForViewers experimental @grafana/dashboards-squad false false false true
141 panelFilterVariable experimental @grafana/dashboards-squad false false false true

View File

@ -562,4 +562,8 @@ const (
// FlagDashboardSceneForViewers
// Enables dashboard rendering using Scenes for viewer roles
FlagDashboardSceneForViewers = "dashboardSceneForViewers"
// FlagPanelFilterVariable
// Enables use of the `systemPanelFilterVar` variable to filter panels in a dashboard
FlagPanelFilterVariable = "panelFilterVariable"
)

View File

@ -1,23 +1,73 @@
import { render } from '@testing-library/react';
import { act, render, screen } from '@testing-library/react';
import React from 'react';
import { Provider } from 'react-redux';
import { Router } from 'react-router-dom';
import { useEffectOnce } from 'react-use';
import { AutoSizerProps } from 'react-virtualized-auto-sizer';
import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock';
import { TextBoxVariableModel } from '@grafana/data';
import { locationService } from '@grafana/runtime';
import { Dashboard } from '@grafana/schema';
import appEvents from 'app/core/app_events';
import { GrafanaContext } from 'app/core/context/GrafanaContext';
import { GetVariables } from 'app/features/variables/state/selectors';
import { VariablesChanged } from 'app/features/variables/types';
import { configureStore } from 'app/store/configureStore';
import { DashboardMeta } from 'app/types';
import { DashboardModel } from '../state';
import { createDashboardModelFixture } from '../state/__fixtures__/dashboardFixtures';
import { DashboardGrid, Props } from './DashboardGrid';
import { DashboardGrid, PANEL_FILTER_VARIABLE, Props } from './DashboardGrid';
import { Props as LazyLoaderProps } from './LazyLoader';
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
config: {
...jest.requireActual('@grafana/runtime').config,
featureToggles: {
panelFilterVariable: true,
},
},
}));
jest.mock('app/features/dashboard/dashgrid/LazyLoader', () => {
const LazyLoader = ({ children }: LazyLoaderProps) => {
return <>{children}</>;
const LazyLoader = ({ children, onLoad }: Pick<LazyLoaderProps, 'children' | 'onLoad'>) => {
useEffectOnce(() => {
onLoad?.();
});
return <>{typeof children === 'function' ? children({ isInView: true }) : children}</>;
};
return { LazyLoader };
});
function getTestDashboard(overrides?: Partial<Dashboard>, metaOverrides?: Partial<DashboardMeta>): DashboardModel {
jest.mock('react-virtualized-auto-sizer', () => {
// The size of the children need to be small enough to be outside the view.
// So it does not trigger the query to be run by the PanelQueryRunner.
return ({ children }: AutoSizerProps) => children({ height: 1, width: 1 });
});
function setup(props: Props) {
const context = getGrafanaContextMock();
const store = configureStore({});
return render(
<GrafanaContext.Provider value={context}>
<Provider store={store}>
<Router history={locationService.getHistory()}>
<DashboardGrid {...props} />
</Router>
</Provider>
</GrafanaContext.Provider>
);
}
function getTestDashboard(
overrides?: Partial<Dashboard>,
metaOverrides?: Partial<DashboardMeta>,
getVariablesFromState?: GetVariables
): DashboardModel {
const data = Object.assign(
{
title: 'My dashboard',
@ -30,20 +80,20 @@ function getTestDashboard(overrides?: Partial<Dashboard>, metaOverrides?: Partia
},
{
id: 2,
type: 'graph2',
title: 'My graph2',
type: 'table',
title: 'My table',
gridPos: { x: 0, y: 10, w: 25, h: 10 },
},
{
id: 3,
type: 'graph3',
title: 'My graph3',
type: 'table',
title: 'My table 2',
gridPos: { x: 0, y: 20, w: 25, h: 100 },
},
{
id: 4,
type: 'graph4',
title: 'My graph4',
type: 'gauge',
title: 'My gauge',
gridPos: { x: 0, y: 120, w: 25, h: 10 },
},
],
@ -51,17 +101,88 @@ function getTestDashboard(overrides?: Partial<Dashboard>, metaOverrides?: Partia
overrides
);
return createDashboardModelFixture(data, metaOverrides);
return createDashboardModelFixture(data, metaOverrides, getVariablesFromState);
}
describe('DashboardGrid', () => {
it('should render without error', () => {
it('Should render panels', async () => {
const props: Props = {
editPanel: null,
viewPanel: null,
isEditable: true,
dashboard: getTestDashboard(),
};
expect(() => render(<DashboardGrid {...props} />)).not.toThrow();
act(() => {
setup(props);
});
expect(await screen.findByText('My graph')).toBeInTheDocument();
expect(await screen.findByText('My table')).toBeInTheDocument();
expect(await screen.findByText('My table 2')).toBeInTheDocument();
expect(await screen.findByText('My gauge')).toBeInTheDocument();
});
it('Should allow filtering panels', async () => {
const props: Props = {
editPanel: null,
viewPanel: null,
isEditable: true,
dashboard: getTestDashboard(),
};
act(() => {
setup(props);
});
act(() => {
appEvents.publish(
new VariablesChanged({
panelIds: [],
refreshAll: false,
variable: {
type: 'textbox',
id: PANEL_FILTER_VARIABLE,
current: {
value: 'My graph',
},
} as TextBoxVariableModel,
})
);
});
const table = screen.queryByText('My table');
const table2 = screen.queryByText('My table 2');
const gauge = screen.queryByText('My gauge');
expect(await screen.findByText('My graph')).toBeInTheDocument();
expect(table).toBeNull();
expect(table2).toBeNull();
expect(gauge).toBeNull();
});
it('Should rendered filtered panels on init when filter variable is present', async () => {
const props: Props = {
editPanel: null,
viewPanel: null,
isEditable: true,
dashboard: getTestDashboard(undefined, undefined, () => [
{
id: PANEL_FILTER_VARIABLE,
type: 'textbox',
query: 'My tab',
} as TextBoxVariableModel,
]),
};
act(() => {
setup(props);
});
const graph = screen.queryByText('My graph');
const gauge = screen.queryByText('My gauge');
expect(await screen.findByText('My table')).toBeInTheDocument();
expect(await screen.findByText('My table 2')).toBeInTheDocument();
expect(graph).toBeNull();
expect(gauge).toBeNull();
});
});

View File

@ -5,8 +5,10 @@ import AutoSizer from 'react-virtualized-auto-sizer';
import { Subscription } from 'rxjs';
import { config } from '@grafana/runtime';
import appEvents from 'app/core/app_events';
import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN, GRID_COLUMN_COUNT } from 'app/core/constants';
import { contextSrv } from 'app/core/services/context_srv';
import { VariablesChanged } from 'app/features/variables/types';
import { DashboardPanelsChangedEvent } from 'app/types/events';
import { AddLibraryPanelWidget } from '../components/AddLibraryPanelWidget';
@ -18,6 +20,8 @@ import { GridPos } from '../state/PanelModel';
import DashboardEmpty from './DashboardEmpty';
import { DashboardPanel } from './DashboardPanel';
export const PANEL_FILTER_VARIABLE = 'systemPanelFilterVar';
export interface Props {
dashboard: DashboardModel;
isEditable: boolean;
@ -25,7 +29,12 @@ export interface Props {
viewPanel: PanelModel | null;
hidePanelMenus?: boolean;
}
export class DashboardGrid extends PureComponent<Props> {
interface State {
panelFilter?: RegExp;
}
export class DashboardGrid extends PureComponent<Props, State> {
private panelMap: { [key: string]: PanelModel } = {};
private eventSubs = new Subscription();
private windowHeight = 1200;
@ -37,10 +46,43 @@ export class DashboardGrid extends PureComponent<Props> {
constructor(props: Props) {
super(props);
this.state = {
panelFilter: undefined,
};
}
componentDidMount() {
const { dashboard } = this.props;
if (config.featureToggles.panelFilterVariable) {
// If panel filter variable is set on load then
// update state to filter panels
for (const variable of dashboard.getVariables()) {
if (variable.id === PANEL_FILTER_VARIABLE) {
if ('query' in variable) {
this.setPanelFilter(variable.query);
}
break;
}
}
this.eventSubs.add(
appEvents.subscribe(VariablesChanged, (e) => {
if (e.payload.variable?.id === PANEL_FILTER_VARIABLE) {
if ('current' in e.payload.variable) {
let variable = e.payload.variable.current;
if ('value' in variable) {
let value = variable.value;
if (typeof value === 'string') {
this.setPanelFilter(value as string);
}
}
}
}
})
);
}
this.eventSubs.add(dashboard.events.subscribe(DashboardPanelsChangedEvent, this.triggerForceUpdate));
}
@ -48,10 +90,25 @@ export class DashboardGrid extends PureComponent<Props> {
this.eventSubs.unsubscribe();
}
setPanelFilter(regex: string) {
// Only set the panels filter if the systemPanelFilterVar variable
// is a non-empty string
let panelFilter = undefined;
if (regex.length > 0) {
panelFilter = new RegExp(regex, 'i');
}
this.setState({
panelFilter: panelFilter,
});
}
buildLayout() {
const layout: ReactGridLayout.Layout[] = [];
this.panelMap = {};
const { panelFilter } = this.state;
let count = 0;
for (const panel of this.props.dashboard.panels) {
if (!panel.key) {
panel.key = `panel-${panel.id}-${Date.now()}`;
@ -78,13 +135,27 @@ export class DashboardGrid extends PureComponent<Props> {
panelPos.isDraggable = panel.collapsed;
}
layout.push(panelPos);
if (!panelFilter) {
layout.push(panelPos);
} else {
if (panelFilter.test(panel.title)) {
panelPos.isResizable = false;
panelPos.isDraggable = false;
panelPos.x = (count % 2) * GRID_COLUMN_COUNT;
panelPos.y = Math.floor(count / 2);
layout.push(panelPos);
count++;
}
}
}
return layout;
}
onLayoutChange = (newLayout: ReactGridLayout.Layout[]) => {
if (this.state.panelFilter) {
return;
}
for (const newPos of newLayout) {
this.panelMap[newPos.i!].updateGridPos(newPos, this.isLayoutInitialized);
}
@ -136,6 +207,7 @@ export class DashboardGrid extends PureComponent<Props> {
}
renderPanels(gridWidth: number, isDashboardDraggable: boolean) {
const { panelFilter } = this.state;
const panelElements = [];
// Reset last panel bottom
@ -156,7 +228,7 @@ export class DashboardGrid extends PureComponent<Props> {
// requires parent create stacking context to prevent overlap with parent elements
const descIndex = this.props.dashboard.panels.length - panelElements.length;
panelElements.push(
const p = (
<GrafanaGridItem
key={panel.key}
className={panelClasses}
@ -173,6 +245,14 @@ export class DashboardGrid extends PureComponent<Props> {
}}
</GrafanaGridItem>
);
if (!panelFilter) {
panelElements.push(p);
} else {
if (panelFilter.test(panel.title)) {
panelElements.push(p);
}
}
}
return panelElements;
@ -295,7 +375,7 @@ interface GrafanaGridItemProps extends React.HTMLAttributes<HTMLDivElement> {
isViewing: boolean;
windowHeight: number;
windowWidth: number;
children: any;
children: any; // eslint-disable-line @typescript-eslint/no-explicit-any
}
/**

View File

@ -623,6 +623,7 @@ export const variableUpdated = (
: {
refreshAll: false,
panelIds: Array.from(getAllAffectedPanelIdsForVariableChange([variableInState.id], g, panelVars)),
variable: getVariable(identifier, state),
};
const node = g.getNode(variableInState.name);

View File

@ -8,6 +8,7 @@ import {
QueryEditorProps,
BaseVariableModel,
VariableHide,
TypedVariableModel,
} from '@grafana/data';
export {
/** @deprecated Import from @grafana/data instead */
@ -95,6 +96,7 @@ export type VariableQueryEditorType<
export interface VariablesChangedEvent {
refreshAll: boolean;
panelIds: number[];
variable?: TypedVariableModel;
}
export class VariablesChanged extends BusEventWithPayload<VariablesChangedEvent> {