From 6bf4d0cbc6756ec8e8fb64b3a9d7b76f4c4194f5 Mon Sep 17 00:00:00 2001 From: Jacob Zelek Date: Fri, 3 Nov 2023 05:15:54 -0700 Subject: [PATCH] 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 --- .betterer.results | 2 +- .../src/types/featureToggles.gen.ts | 1 + pkg/services/featuremgmt/registry.go | 8 + pkg/services/featuremgmt/toggles_gen.csv | 1 + pkg/services/featuremgmt/toggles_gen.go | 4 + .../dashboard/dashgrid/DashboardGrid.test.tsx | 149 ++++++++++++++++-- .../dashboard/dashgrid/DashboardGrid.tsx | 88 ++++++++++- .../app/features/variables/state/actions.ts | 1 + public/app/features/variables/types.ts | 2 + 9 files changed, 237 insertions(+), 19 deletions(-) diff --git a/.betterer.results b/.betterer.results index 4b6eb1946a2..c3508a49fe3 100644 --- a/.betterer.results +++ b/.betterer.results @@ -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"] diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 9315579eb18..e21dc9e57e5 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -157,4 +157,5 @@ export interface FeatureToggles { annotationPermissionUpdate?: boolean; extractFieldsNameDeduplication?: boolean; dashboardSceneForViewers?: boolean; + panelFilterVariable?: boolean; } diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index d28bca5998e..8170e1766cd 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -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, + }, } ) diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 8276c937fa3..7fe4cb32ae7 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -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 diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index bd3493e5147..4ea5dd8b5b0 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -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" ) diff --git a/public/app/features/dashboard/dashgrid/DashboardGrid.test.tsx b/public/app/features/dashboard/dashgrid/DashboardGrid.test.tsx index 853391201a0..c8c439eacb9 100644 --- a/public/app/features/dashboard/dashgrid/DashboardGrid.test.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardGrid.test.tsx @@ -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) => { + useEffectOnce(() => { + onLoad?.(); + }); + return <>{typeof children === 'function' ? children({ isInView: true }) : children}; }; return { LazyLoader }; }); -function getTestDashboard(overrides?: Partial, metaOverrides?: Partial): 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( + + + + + + + + ); +} + +function getTestDashboard( + overrides?: Partial, + metaOverrides?: Partial, + getVariablesFromState?: GetVariables +): DashboardModel { const data = Object.assign( { title: 'My dashboard', @@ -30,20 +80,20 @@ function getTestDashboard(overrides?: Partial, 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, 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()).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(); }); }); diff --git a/public/app/features/dashboard/dashgrid/DashboardGrid.tsx b/public/app/features/dashboard/dashgrid/DashboardGrid.tsx index 924944700dc..b4220cc25c7 100644 --- a/public/app/features/dashboard/dashgrid/DashboardGrid.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardGrid.tsx @@ -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 { + +interface State { + panelFilter?: RegExp; +} + +export class DashboardGrid extends PureComponent { private panelMap: { [key: string]: PanelModel } = {}; private eventSubs = new Subscription(); private windowHeight = 1200; @@ -37,10 +46,43 @@ export class DashboardGrid extends PureComponent { 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 { 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 { 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 { } renderPanels(gridWidth: number, isDashboardDraggable: boolean) { + const { panelFilter } = this.state; const panelElements = []; // Reset last panel bottom @@ -156,7 +228,7 @@ export class DashboardGrid extends PureComponent { // requires parent create stacking context to prevent overlap with parent elements const descIndex = this.props.dashboard.panels.length - panelElements.length; - panelElements.push( + const p = ( { }} ); + + 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 { isViewing: boolean; windowHeight: number; windowWidth: number; - children: any; + children: any; // eslint-disable-line @typescript-eslint/no-explicit-any } /** diff --git a/public/app/features/variables/state/actions.ts b/public/app/features/variables/state/actions.ts index 9e4f3d4b812..53f0b96d5a1 100644 --- a/public/app/features/variables/state/actions.ts +++ b/public/app/features/variables/state/actions.ts @@ -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); diff --git a/public/app/features/variables/types.ts b/public/app/features/variables/types.ts index 11c66dcfafd..f943bf1efd4 100644 --- a/public/app/features/variables/types.ts +++ b/public/app/features/variables/types.ts @@ -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 {