Dynamic dashboards: Repeat responsive grid items (#101291)

* repeat responsive grid items

* fix stuff from feedback

* Simplify repeat dependency, fix locale stuff
This commit is contained in:
Oscar Kilhed
2025-02-27 15:11:42 +01:00
committed by GitHub
parent 5eb3f3a3cb
commit 21be5e3f45
8 changed files with 170 additions and 5 deletions

View File

@ -574,6 +574,7 @@ ResponsiveGridLayoutItemKind: {
ResponsiveGridLayoutItemSpec: { ResponsiveGridLayoutItemSpec: {
element: ElementReference element: ElementReference
repeat?: ResponsiveGridRepeatOptions
} }
TabsLayoutKind: { TabsLayoutKind: {

View File

@ -867,6 +867,7 @@ export const defaultResponsiveGridLayoutItemKind = (): ResponsiveGridLayoutItemK
export interface ResponsiveGridLayoutItemSpec { export interface ResponsiveGridLayoutItemSpec {
element: ElementReference; element: ElementReference;
repeat?: ResponsiveGridRepeatOptions;
} }
export const defaultResponsiveGridLayoutItemSpec = (): ResponsiveGridLayoutItemSpec => ({ export const defaultResponsiveGridLayoutItemSpec = (): ResponsiveGridLayoutItemSpec => ({

View File

@ -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 { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
import { getCloneKey } from '../../utils/clone';
import { getMultiVariableValues } from '../../utils/utils';
import { DashboardLayoutItem } from '../types/DashboardLayoutItem'; import { DashboardLayoutItem } from '../types/DashboardLayoutItem';
import { DashboardRepeatsProcessedEvent } from '../types/DashboardRepeatsProcessedEvent';
import { getOptions } from './ResponsiveGridItemEditor'; import { getOptions } from './ResponsiveGridItemEditor';
import { ResponsiveGridItemRenderer } from './ResponsiveGridItemRenderer'; import { ResponsiveGridItemRenderer } from './ResponsiveGridItemRenderer';
@ -9,13 +26,30 @@ import { ResponsiveGridItemRenderer } from './ResponsiveGridItemRenderer';
export interface ResponsiveGridItemState extends SceneObjectState { export interface ResponsiveGridItemState extends SceneObjectState {
body: VizPanel; body: VizPanel;
hideWhenNoData?: boolean; hideWhenNoData?: boolean;
repeatedPanels?: VizPanel[];
variableName?: string;
} }
export class ResponsiveGridItem extends SceneObjectBase<ResponsiveGridItemState> implements DashboardLayoutItem { export class ResponsiveGridItem extends SceneObjectBase<ResponsiveGridItemState> implements DashboardLayoutItem {
public static Component = ResponsiveGridItemRenderer; 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 readonly isDashboardLayoutItem = true;
public constructor(state: ResponsiveGridItemState) {
super(state);
this.addActivationHandler(() => this._activationHandler());
}
private _activationHandler() {
if (this.state.variableName) {
this.performRepeat();
}
}
public getOptions(): OptionsPaneCategoryDescriptor { public getOptions(): OptionsPaneCategoryDescriptor {
return getOptions(this); return getOptions(this);
} }
@ -23,4 +57,78 @@ export class ResponsiveGridItem extends SceneObjectBase<ResponsiveGridItemState>
public toggleHideWhenNoData() { public toggleHideWhenNoData() {
this.setState({ hideWhenNoData: !this.state.hideWhenNoData }); 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<VizPanelState> = {
$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<ResponsiveGridItemState> = { variableName };
if (this.state.body.state.$variables) {
this.state.body.setState({ $variables: undefined });
}
this._variableDependency.setVariableNames(variableName ? [variableName] : []);
this.setState(stateUpdate);
this.performRepeat();
}
} }

View File

@ -2,6 +2,7 @@ import { Switch } from '@grafana/ui';
import { t } from 'app/core/internationalization'; import { t } from 'app/core/internationalization';
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor'; import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor'; import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
import { RepeatRowSelect2 } from 'app/features/dashboard/components/RepeatRowSelect/RepeatRowSelect';
import { ResponsiveGridItem } from './ResponsiveGridItem'; 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: () => <RepeatByOption item={model} />,
})
);
return category; return category;
} }
@ -27,3 +39,16 @@ function GridItemNoDataToggle({ item }: { item: ResponsiveGridItem }) {
return <Switch value={hideWhenNoData} id="hide-when-no-data" onChange={() => item.toggleHideWhenNoData()} />; return <Switch value={hideWhenNoData} id="hide-when-no-data" onChange={() => item.toggleHideWhenNoData()} />;
} }
function RepeatByOption({ item }: { item: ResponsiveGridItem }) {
const { variableName } = item.useState();
return (
<RepeatRowSelect2
id="repeat-by-variable-select"
sceneContext={item}
repeat={variableName}
onChange={(value?: string) => item.setRepeatByVariable(value)}
/>
);
}

View File

@ -9,7 +9,15 @@ export function ResponsiveGridItemRenderer({ model }: SceneComponentProps<Respon
const { body } = model.useState(); const { body } = model.useState();
const style = useStyles2(getStyles); const style = useStyles2(getStyles);
return ( return model.state.repeatedPanels ? (
<>
{model.state.repeatedPanels.map((item) => (
<div className={cx(style.wrapper)} key={item.state.key}>
<item.Component model={item} />
</div>
))}
</>
) : (
<div className={cx(style.wrapper)}> <div className={cx(style.wrapper)}>
<body.Component model={body} /> <body.Component model={body} />
</div> </div>

View File

@ -1,5 +1,5 @@
import { SceneCSSGridLayout } from '@grafana/scenes'; 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 { ResponsiveGridItem } from '../../scene/layout-responsive-grid/ResponsiveGridItem';
import { ResponsiveGridLayoutManager } from '../../scene/layout-responsive-grid/ResponsiveGridLayoutManager'; import { ResponsiveGridLayoutManager } from '../../scene/layout-responsive-grid/ResponsiveGridLayoutManager';
@ -21,7 +21,7 @@ export class ResponsiveGridLayoutSerializer implements LayoutManagerSerializer {
if (!(child instanceof ResponsiveGridItem)) { if (!(child instanceof ResponsiveGridItem)) {
throw new Error('Expected ResponsiveGridItem'); throw new Error('Expected ResponsiveGridItem');
} }
return { const layoutItem: ResponsiveGridLayoutItemKind = {
kind: 'ResponsiveGridLayoutItem', kind: 'ResponsiveGridLayoutItem',
spec: { spec: {
element: { 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({ return new ResponsiveGridItem({
key: getGridItemKeyForPanelId(panel.spec.id), key: getGridItemKeyForPanelId(panel.spec.id),
body: buildVizPanel(panel), body: buildVizPanel(panel),
variableName: item.spec.repeat?.value,
}); });
}); });

View File

@ -1184,6 +1184,12 @@
"description": "CSS layout that adjusts to the available space", "description": "CSS layout that adjusts to the available space",
"item-options": { "item-options": {
"hide-no-data": "Hide when no data", "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" "title": "Layout options"
}, },
"name": "Responsive grid", "name": "Responsive grid",

View File

@ -1184,6 +1184,12 @@
"description": "CŜŜ ľäyőūŧ ŧĥäŧ äđĵūşŧş ŧő ŧĥę äväįľäþľę şpäčę", "description": "CŜŜ ľäyőūŧ ŧĥäŧ äđĵūşŧş ŧő ŧĥę äväįľäþľę şpäčę",
"item-options": { "item-options": {
"hide-no-data": "Ħįđę ŵĥęʼn ʼnő đäŧä", "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ş" "title": "Ŀäyőūŧ őpŧįőʼnş"
}, },
"name": "Ŗęşpőʼnşįvę ģřįđ", "name": "Ŗęşpőʼnşįvę ģřįđ",