From 21be5e3f45b4314c8cc1440426daafb7192e513d Mon Sep 17 00:00:00 2001 From: Oscar Kilhed Date: Thu, 27 Feb 2025 15:11:42 +0100 Subject: [PATCH] Dynamic dashboards: Repeat responsive grid items (#101291) * repeat responsive grid items * fix stuff from feedback * Simplify repeat dependency, fix locale stuff --- .../dashboard/v2alpha0/dashboard.schema.cue | 1 + .../schema/dashboard/v2alpha0/types.gen.ts | 1 + .../ResponsiveGridItem.tsx | 112 +++++++++++++++++- .../ResponsiveGridItemEditor.tsx | 25 ++++ .../ResponsiveGridItemRenderer.tsx | 10 +- .../ResponsiveGridLayoutSerializer.ts | 14 ++- public/locales/en-US/grafana.json | 6 + public/locales/pseudo-LOCALE/grafana.json | 6 + 8 files changed, 170 insertions(+), 5 deletions(-) diff --git a/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue b/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue index a8a7511443d..bb799f4053e 100644 --- a/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue +++ b/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue @@ -574,6 +574,7 @@ ResponsiveGridLayoutItemKind: { ResponsiveGridLayoutItemSpec: { element: ElementReference + repeat?: ResponsiveGridRepeatOptions } TabsLayoutKind: { diff --git a/packages/grafana-schema/src/schema/dashboard/v2alpha0/types.gen.ts b/packages/grafana-schema/src/schema/dashboard/v2alpha0/types.gen.ts index a8fe2500f3a..77bb17e5d4f 100644 --- a/packages/grafana-schema/src/schema/dashboard/v2alpha0/types.gen.ts +++ b/packages/grafana-schema/src/schema/dashboard/v2alpha0/types.gen.ts @@ -867,6 +867,7 @@ export const defaultResponsiveGridLayoutItemKind = (): ResponsiveGridLayoutItemK export interface ResponsiveGridLayoutItemSpec { element: ElementReference; + repeat?: ResponsiveGridRepeatOptions; } export const defaultResponsiveGridLayoutItemSpec = (): ResponsiveGridLayoutItemSpec => ({ diff --git a/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridItem.tsx b/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridItem.tsx index a201a6a38f7..e7b9010a3d5 100644 --- a/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridItem.tsx +++ b/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridItem.tsx @@ -1,7 +1,24 @@ -import { SceneObjectState, VizPanel, SceneObjectBase } from '@grafana/scenes'; +import { isEqual } from 'lodash'; + +import { + SceneObjectState, + VizPanel, + SceneObjectBase, + sceneGraph, + CustomVariable, + MultiValueVariable, + VariableValueSingle, + VizPanelState, + SceneVariableSet, + LocalValueVariable, + VariableDependencyConfig, +} from '@grafana/scenes'; import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor'; +import { getCloneKey } from '../../utils/clone'; +import { getMultiVariableValues } from '../../utils/utils'; import { DashboardLayoutItem } from '../types/DashboardLayoutItem'; +import { DashboardRepeatsProcessedEvent } from '../types/DashboardRepeatsProcessedEvent'; import { getOptions } from './ResponsiveGridItemEditor'; import { ResponsiveGridItemRenderer } from './ResponsiveGridItemRenderer'; @@ -9,13 +26,30 @@ import { ResponsiveGridItemRenderer } from './ResponsiveGridItemRenderer'; export interface ResponsiveGridItemState extends SceneObjectState { body: VizPanel; hideWhenNoData?: boolean; + repeatedPanels?: VizPanel[]; + variableName?: string; } export class ResponsiveGridItem extends SceneObjectBase implements DashboardLayoutItem { public static Component = ResponsiveGridItemRenderer; - + private _prevRepeatValues?: VariableValueSingle[]; + protected _variableDependency = new VariableDependencyConfig(this, { + variableNames: this.state.variableName ? [this.state.variableName] : [], + onVariableUpdateCompleted: () => this.performRepeat(), + }); public readonly isDashboardLayoutItem = true; + public constructor(state: ResponsiveGridItemState) { + super(state); + this.addActivationHandler(() => this._activationHandler()); + } + + private _activationHandler() { + if (this.state.variableName) { + this.performRepeat(); + } + } + public getOptions(): OptionsPaneCategoryDescriptor { return getOptions(this); } @@ -23,4 +57,78 @@ export class ResponsiveGridItem extends SceneObjectBase public toggleHideWhenNoData() { this.setState({ hideWhenNoData: !this.state.hideWhenNoData }); } + + public performRepeat() { + if (!this.state.variableName || sceneGraph.hasVariableDependencyInLoadingState(this)) { + return; + } + + const variable = + sceneGraph.lookupVariable(this.state.variableName, this) ?? + new CustomVariable({ + name: '_____default_sys_repeat_var_____', + options: [], + value: '', + text: '', + query: 'A', + }); + + if (!(variable instanceof MultiValueVariable)) { + console.error('DashboardGridItem: Variable is not a MultiValueVariable'); + return; + } + + const { values, texts } = getMultiVariableValues(variable); + + if (isEqual(this._prevRepeatValues, values)) { + return; + } + + const panelToRepeat = this.state.body; + const repeatedPanels: VizPanel[] = []; + + // when variable has no options (due to error or similar) it will not render any panels at all + // adding a placeholder in this case so that there is at least empty panel that can display error + const emptyVariablePlaceholderOption = { + values: [''], + texts: variable.hasAllValue() ? ['All'] : ['None'], + }; + + const variableValues = values.length ? values : emptyVariablePlaceholderOption.values; + const variableTexts = texts.length ? texts : emptyVariablePlaceholderOption.texts; + for (let index = 0; index < variableValues.length; index++) { + const cloneState: Partial = { + $variables: new SceneVariableSet({ + variables: [ + new LocalValueVariable({ + name: variable.state.name, + value: variableValues[index], + text: String(variableTexts[index]), + }), + ], + }), + key: getCloneKey(panelToRepeat.state.key!, index), + }; + const clone = panelToRepeat.clone(cloneState); + repeatedPanels.push(clone); + } + + this.setState({ repeatedPanels }); + this._prevRepeatValues = values; + + this.publishEvent(new DashboardRepeatsProcessedEvent({ source: this }), true); + } + + public setRepeatByVariable(variableName: string | undefined) { + const stateUpdate: Partial = { variableName }; + + if (this.state.body.state.$variables) { + this.state.body.setState({ $variables: undefined }); + } + + this._variableDependency.setVariableNames(variableName ? [variableName] : []); + + this.setState(stateUpdate); + this.performRepeat(); + } } diff --git a/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridItemEditor.tsx b/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridItemEditor.tsx index d1e96b04f75..47be0b1272a 100644 --- a/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridItemEditor.tsx +++ b/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridItemEditor.tsx @@ -2,6 +2,7 @@ import { Switch } from '@grafana/ui'; import { t } from 'app/core/internationalization'; import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor'; import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor'; +import { RepeatRowSelect2 } from 'app/features/dashboard/components/RepeatRowSelect/RepeatRowSelect'; import { ResponsiveGridItem } from './ResponsiveGridItem'; @@ -19,6 +20,17 @@ export function getOptions(model: ResponsiveGridItem): OptionsPaneCategoryDescri }) ); + category.addItem( + new OptionsPaneItemDescriptor({ + title: t('dashboard.responsive-layout.item-options.repeat.variable.title', 'Repeat by variable'), + description: t( + 'dashboard.responsive-layout.item-options.repeat.variable.description', + 'Repeat this panel for each value in the selected variable. This is not visible while in edit mode. You need to go back to dashboard and then update the variable or reload the dashboard.' + ), + render: () => , + }) + ); + return category; } @@ -27,3 +39,16 @@ function GridItemNoDataToggle({ item }: { item: ResponsiveGridItem }) { return item.toggleHideWhenNoData()} />; } + +function RepeatByOption({ item }: { item: ResponsiveGridItem }) { + const { variableName } = item.useState(); + + return ( + item.setRepeatByVariable(value)} + /> + ); +} diff --git a/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridItemRenderer.tsx b/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridItemRenderer.tsx index 4a6aad0f385..9a039becb97 100644 --- a/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridItemRenderer.tsx +++ b/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridItemRenderer.tsx @@ -9,7 +9,15 @@ export function ResponsiveGridItemRenderer({ model }: SceneComponentProps + {model.state.repeatedPanels.map((item) => ( +
+ +
+ ))} + + ) : (
diff --git a/public/app/features/dashboard-scene/serialization/layoutSerializers/ResponsiveGridLayoutSerializer.ts b/public/app/features/dashboard-scene/serialization/layoutSerializers/ResponsiveGridLayoutSerializer.ts index f97b7dd34c6..4e5bb1520bb 100644 --- a/public/app/features/dashboard-scene/serialization/layoutSerializers/ResponsiveGridLayoutSerializer.ts +++ b/public/app/features/dashboard-scene/serialization/layoutSerializers/ResponsiveGridLayoutSerializer.ts @@ -1,5 +1,5 @@ import { SceneCSSGridLayout } from '@grafana/scenes'; -import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0'; +import { DashboardV2Spec, ResponsiveGridLayoutItemKind } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0'; import { ResponsiveGridItem } from '../../scene/layout-responsive-grid/ResponsiveGridItem'; import { ResponsiveGridLayoutManager } from '../../scene/layout-responsive-grid/ResponsiveGridLayoutManager'; @@ -21,7 +21,7 @@ export class ResponsiveGridLayoutSerializer implements LayoutManagerSerializer { if (!(child instanceof ResponsiveGridItem)) { throw new Error('Expected ResponsiveGridItem'); } - return { + const layoutItem: ResponsiveGridLayoutItemKind = { kind: 'ResponsiveGridLayoutItem', spec: { element: { @@ -30,6 +30,15 @@ export class ResponsiveGridLayoutSerializer implements LayoutManagerSerializer { }, }, }; + + if (child.state.variableName) { + layoutItem.spec.repeat = { + mode: 'variable', + value: child.state.variableName, + }; + } + + return layoutItem; }), }, }; @@ -51,6 +60,7 @@ export class ResponsiveGridLayoutSerializer implements LayoutManagerSerializer { return new ResponsiveGridItem({ key: getGridItemKeyForPanelId(panel.spec.id), body: buildVizPanel(panel), + variableName: item.spec.repeat?.value, }); }); diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 37a8b593604..e014860e85c 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -1184,6 +1184,12 @@ "description": "CSS layout that adjusts to the available space", "item-options": { "hide-no-data": "Hide when no data", + "repeat": { + "variable": { + "description": "Repeat this panel for each value in the selected variable. This is not visible while in edit mode. You need to go back to dashboard and then update the variable or reload the dashboard.", + "title": "Repeat by variable" + } + }, "title": "Layout options" }, "name": "Responsive grid", diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index e4812b0b3af..cb3c713ad28 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -1184,6 +1184,12 @@ "description": "CŜŜ ľäyőūŧ ŧĥäŧ äđĵūşŧş ŧő ŧĥę äväįľäþľę şpäčę", "item-options": { "hide-no-data": "Ħįđę ŵĥęʼn ʼnő đäŧä", + "repeat": { + "variable": { + "description": "Ŗępęäŧ ŧĥįş päʼnęľ ƒőř ęäčĥ väľūę įʼn ŧĥę şęľęčŧęđ väřįäþľę. Ŧĥįş įş ʼnőŧ vįşįþľę ŵĥįľę įʼn ęđįŧ mőđę. Ÿőū ʼnęęđ ŧő ģő þäčĸ ŧő đäşĥþőäřđ äʼnđ ŧĥęʼn ūpđäŧę ŧĥę väřįäþľę őř řęľőäđ ŧĥę đäşĥþőäřđ.", + "title": "Ŗępęäŧ þy väřįäþľę" + } + }, "title": "Ŀäyőūŧ őpŧįőʼnş" }, "name": "Ŗęşpőʼnşįvę ģřįđ",