Dynamic Dashboards: Add repeats for tabs (#103348)

pull/102897/head
Bogdan Matei 3 months ago committed by GitHub
parent 9b6021c490
commit 35e78feffa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 10
      apps/dashboard/kinds/v2alpha1/dashboard_spec.cue
  2. 12
      apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec_gen.go
  3. 35
      apps/dashboard/pkg/apis/dashboard/v2alpha1/zz_generated.openapi.go
  4. 8
      packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue
  5. 14
      packages/grafana-schema/src/schema/dashboard/v2alpha0/types.gen.ts
  6. 14
      packages/grafana-schema/src/schema/dashboard/v2alpha1/types.spec.gen.ts
  7. 20
      pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v2alpha1.json
  8. 41
      public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx
  9. 26
      public/app/features/dashboard-scene/scene/layout-tabs/TabItem.tsx
  10. 78
      public/app/features/dashboard-scene/scene/layout-tabs/TabItemEditor.tsx
  11. 6
      public/app/features/dashboard-scene/scene/layout-tabs/TabItemRenderer.tsx
  12. 272
      public/app/features/dashboard-scene/scene/layout-tabs/TabItemRepeaterBehavior.test.tsx
  13. 161
      public/app/features/dashboard-scene/scene/layout-tabs/TabItemRepeaterBehavior.ts
  14. 47
      public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManager.tsx
  15. 13
      public/app/features/dashboard-scene/serialization/layoutSerializers/RowsLayoutSerializer.ts
  16. 20
      public/app/features/dashboard-scene/serialization/layoutSerializers/TabsLayoutSerializer.ts
  17. 13
      public/locales/en-US/grafana.json

@ -495,6 +495,11 @@ RowRepeatOptions: {
value: string
}
TabRepeatOptions: {
mode: RepeatMode
value: string
}
AutoGridRepeatOptions: {
mode: RepeatMode
value: string
@ -523,8 +528,8 @@ GridLayoutRowSpec: {
y: int
collapsed: bool
title: string
elements: [...GridLayoutItemKind] // Grid items in the row will have their Y value be relative to the rows Y value. This means a panel positioned at Y: 0 in a row with Y: 10 will be positioned at Y: 11 (row header has a heigh of 1) in the dashboard.
repeat?: RowRepeatOptions
elements: [...GridLayoutItemKind] // Grid items in the row will have their Y value be relative to the rows Y value. This means a panel positioned at Y: 0 in a row with Y: 10 will be positioned at Y: 11 (row header has a heigh of 1) in the dashboard.
repeat?: RowRepeatOptions
}
GridLayoutSpec: {
@ -604,6 +609,7 @@ TabsLayoutTabSpec: {
title?: string
layout: GridLayoutKind | RowsLayoutKind | AutoGridLayoutKind | TabsLayoutKind
conditionalRendering?: ConditionalRenderingGroupKind
repeat?: TabRepeatOptions
}
PanelSpec: {

@ -1069,6 +1069,7 @@ type DashboardTabsLayoutTabSpec struct {
Title *string `json:"title,omitempty"`
Layout DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind `json:"layout"`
ConditionalRendering *DashboardConditionalRenderingGroupKind `json:"conditionalRendering,omitempty"`
Repeat *DashboardTabRepeatOptions `json:"repeat,omitempty"`
}
// NewDashboardTabsLayoutTabSpec creates a new DashboardTabsLayoutTabSpec object.
@ -1078,6 +1079,17 @@ func NewDashboardTabsLayoutTabSpec() *DashboardTabsLayoutTabSpec {
}
}
// +k8s:openapi-gen=true
type DashboardTabRepeatOptions struct {
Mode string `json:"mode"`
Value string `json:"value"`
}
// NewDashboardTabRepeatOptions creates a new DashboardTabRepeatOptions object.
func NewDashboardTabRepeatOptions() *DashboardTabRepeatOptions {
return &DashboardTabRepeatOptions{}
}
// Links with references to other dashboards or external resources
// +k8s:openapi-gen=true
type DashboardDashboardLink struct {

@ -100,6 +100,7 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardStatus": schema_pkg_apis_dashboard_v2alpha1_DashboardStatus(ref),
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardStringOrArrayOfString": schema_pkg_apis_dashboard_v2alpha1_DashboardStringOrArrayOfString(ref),
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardStringOrFloat64": schema_pkg_apis_dashboard_v2alpha1_DashboardStringOrFloat64(ref),
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardTabRepeatOptions": schema_pkg_apis_dashboard_v2alpha1_DashboardTabRepeatOptions(ref),
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardTabsLayoutKind": schema_pkg_apis_dashboard_v2alpha1_DashboardTabsLayoutKind(ref),
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardTabsLayoutSpec": schema_pkg_apis_dashboard_v2alpha1_DashboardTabsLayoutSpec(ref),
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardTabsLayoutTabKind": schema_pkg_apis_dashboard_v2alpha1_DashboardTabsLayoutTabKind(ref),
@ -3921,6 +3922,33 @@ func schema_pkg_apis_dashboard_v2alpha1_DashboardStringOrFloat64(ref common.Refe
}
}
func schema_pkg_apis_dashboard_v2alpha1_DashboardTabRepeatOptions(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"mode": {
SchemaProps: spec.SchemaProps{
Default: "",
Type: []string{"string"},
Format: "",
},
},
"value": {
SchemaProps: spec.SchemaProps{
Default: "",
Type: []string{"string"},
Format: "",
},
},
},
Required: []string{"mode", "value"},
},
},
}
}
func schema_pkg_apis_dashboard_v2alpha1_DashboardTabsLayoutKind(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
@ -4027,12 +4055,17 @@ func schema_pkg_apis_dashboard_v2alpha1_DashboardTabsLayoutTabSpec(ref common.Re
Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardConditionalRenderingGroupKind"),
},
},
"repeat": {
SchemaProps: spec.SchemaProps{
Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardTabRepeatOptions"),
},
},
},
Required: []string{"layout"},
},
},
Dependencies: []string{
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardConditionalRenderingGroupKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind"},
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardConditionalRenderingGroupKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardTabRepeatOptions"},
}
}

@ -495,6 +495,11 @@ RowRepeatOptions: {
value: string
}
TabRepeatOptions: {
mode: RepeatMode,
value: string
}
AutoGridRepeatOptions: {
mode: RepeatMode
value: string
@ -603,6 +608,7 @@ TabsLayoutTabKind: {
TabsLayoutTabSpec: {
title?: string
layout: GridLayoutKind | RowsLayoutKind | AutoGridLayoutKind | TabsLayoutKind
repeat?: TabRepeatOptions
conditionalRendering?: ConditionalRenderingGroupKind
}
@ -962,4 +968,4 @@ ConditionalRenderingTimeRangeSizeKind: {
ConditionalRenderingTimeRangeSizeSpec: {
value: string
}
}

@ -327,7 +327,7 @@ export interface FieldConfig {
description?: string;
// An explicit path to the field in the datasource. When the frame meta includes a path,
// This will default to `${frame.meta.path}/${field.name}
//
//
// When defined, this value can be used as an identifier within the datasource scope, and
// may be used to update the results
path?: string;
@ -916,6 +916,7 @@ export const defaultTabsLayoutTabKind = (): TabsLayoutTabKind => ({
export interface TabsLayoutTabSpec {
title?: string;
layout: GridLayoutKind | RowsLayoutKind | AutoGridLayoutKind | TabsLayoutKind;
repeat?: TabRepeatOptions;
conditionalRendering?: ConditionalRenderingGroupKind;
}
@ -923,6 +924,16 @@ export const defaultTabsLayoutTabSpec = (): TabsLayoutTabSpec => ({
layout: defaultGridLayoutKind(),
});
export interface TabRepeatOptions {
mode: "variable";
value: string;
}
export const defaultTabRepeatOptions = (): TabRepeatOptions => ({
mode: RepeatMode,
value: "",
});
// Links with references to other dashboards or external resources
export interface DashboardLink {
// Title to display with the link
@ -1492,4 +1503,3 @@ export const defaultVariableValueOption = (): VariableValueOption => ({
label: "",
value: defaultVariableValueSingle(),
});

@ -282,7 +282,7 @@ export interface FieldConfig {
description?: string;
// An explicit path to the field in the datasource. When the frame meta includes a path,
// This will default to `${frame.meta.path}/${field.name}
//
//
// When defined, this value can be used as an identifier within the datasource scope, and
// may be used to update the results
path?: string;
@ -872,12 +872,23 @@ export interface TabsLayoutTabSpec {
title?: string;
layout: GridLayoutKind | RowsLayoutKind | AutoGridLayoutKind | TabsLayoutKind;
conditionalRendering?: ConditionalRenderingGroupKind;
repeat?: TabRepeatOptions;
}
export const defaultTabsLayoutTabSpec = (): TabsLayoutTabSpec => ({
layout: defaultGridLayoutKind(),
});
export interface TabRepeatOptions {
mode: "variable";
value: string;
}
export const defaultTabRepeatOptions = (): TabRepeatOptions => ({
mode: RepeatMode,
value: "",
});
// Links with references to other dashboards or external resources
export interface DashboardLink {
// Title to display with the link
@ -1402,4 +1413,3 @@ export const defaultSpec = (): Spec => ({
title: "",
variables: [],
});

@ -3445,6 +3445,23 @@
}
}
},
"com.github.grafana.grafana.apps.dashboard.pkg.apis.dashboard.v2alpha1.DashboardTabRepeatOptions": {
"type": "object",
"required": [
"mode",
"value"
],
"properties": {
"mode": {
"type": "string",
"default": ""
},
"value": {
"type": "string",
"default": ""
}
}
},
"com.github.grafana.grafana.apps.dashboard.pkg.apis.dashboard.v2alpha1.DashboardTabsLayoutKind": {
"type": "object",
"required": [
@ -3518,6 +3535,9 @@
"layout": {
"$ref": "#/components/schemas/com.github.grafana.grafana.apps.dashboard.pkg.apis.dashboard.v2alpha1.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind"
},
"repeat": {
"$ref": "#/components/schemas/com.github.grafana.grafana.apps.dashboard.pkg.apis.dashboard.v2alpha1.DashboardTabRepeatOptions"
},
"title": {
"type": "string"
}

@ -1,4 +1,11 @@
import { SceneGridItemLike, SceneGridRow, SceneObjectBase, SceneObjectState, VizPanel } from '@grafana/scenes';
import {
sceneGraph,
SceneGridItemLike,
SceneGridRow,
SceneObjectBase,
SceneObjectState,
VizPanel,
} from '@grafana/scenes';
import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen';
import { t } from 'app/core/internationalization';
@ -8,12 +15,13 @@ import {
ObjectsReorderedOnCanvasEvent,
} from '../../edit-pane/shared';
import { serializeRowsLayout } from '../../serialization/layoutSerializers/RowsLayoutSerializer';
import { isClonedKey } from '../../utils/clone';
import { isClonedKey, joinCloneKeys } from '../../utils/clone';
import { dashboardSceneGraph } from '../../utils/dashboardSceneGraph';
import { getDashboardSceneFor } from '../../utils/utils';
import { DashboardGridItem } from '../layout-default/DashboardGridItem';
import { DefaultGridLayoutManager } from '../layout-default/DefaultGridLayoutManager';
import { RowRepeaterBehavior } from '../layout-default/RowRepeaterBehavior';
import { TabItemRepeaterBehavior } from '../layout-tabs/TabItemRepeaterBehavior';
import { TabsLayoutManager } from '../layout-tabs/TabsLayoutManager';
import { getRowFromClipboard } from '../layouts-shared/paste';
import { generateUniqueTitle, ungroupLayout } from '../layouts-shared/utils';
@ -81,7 +89,16 @@ export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> i
}
public cloneLayout(ancestorKey: string, isSource: boolean): DashboardLayoutManager {
throw new Error('Method not implemented.');
return this.clone({
rows: this.state.rows.map((row) => {
const key = joinCloneKeys(ancestorKey, row.state.key!);
return row.clone({
key,
layout: row.state.layout.cloneLayout(key, isSource),
});
}),
});
}
public duplicate(): DashboardLayoutManager {
@ -179,7 +196,21 @@ export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> i
if (layout instanceof TabsLayoutManager) {
for (const tab of layout.state.tabs) {
rows.push(new RowItem({ layout: tab.state.layout.clone(), title: tab.state.title }));
if (isClonedKey(tab.state.key!)) {
continue;
}
const conditionalRendering = tab.state.conditionalRendering;
conditionalRendering?.clearParent();
const behavior = tab.state.$behaviors?.find((b) => b instanceof TabItemRepeaterBehavior);
const $behaviors = !behavior
? undefined
: [new RowItemRepeaterBehavior({ variableName: behavior.state.variableName })];
rows.push(
new RowItem({ layout: tab.state.layout.clone(), title: tab.state.title, conditionalRendering, $behaviors })
);
}
} else if (layout instanceof DefaultGridLayoutManager) {
const config: Array<{
@ -253,7 +284,7 @@ export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> i
const duplicateTitles = new Set<string | undefined>();
this.state.rows.forEach((row) => {
const title = row.state.title;
const title = sceneGraph.interpolate(row, row.state.title);
const count = (titleCounts.get(title) ?? 0) + 1;
titleCounts.set(title, count);
if (count > 1 && title) {

@ -33,6 +33,7 @@ import { LayoutParent } from '../types/LayoutParent';
import { useEditOptions } from './TabItemEditor';
import { TabItemRenderer } from './TabItemRenderer';
import { TabItemRepeaterBehavior } from './TabItemRepeaterBehavior';
import { TabItems } from './TabItems';
import { TabsLayoutManager } from './TabsLayoutManager';
@ -176,6 +177,23 @@ export class TabItem
this.onChangeTitle(name);
}
public onChangeRepeat(repeat: string | undefined) {
let repeatBehavior = this._getRepeatBehavior();
if (repeat) {
// Remove repeat behavior if it exists to trigger repeat when adding new one
if (repeatBehavior) {
repeatBehavior.removeBehavior();
}
repeatBehavior = new TabItemRepeaterBehavior({ variableName: repeat });
this.setState({ $behaviors: [...(this.state.$behaviors ?? []), repeatBehavior] });
repeatBehavior.activate();
} else {
repeatBehavior?.removeBehavior();
}
}
public setIsDropTarget(isDropTarget: boolean) {
if (!!this.state.isDropTarget !== isDropTarget) {
this.setState({ isDropTarget });
@ -199,6 +217,10 @@ export class TabItem
}
}
public getRepeatVariable(): string | undefined {
return this._getRepeatBehavior()?.state.variableName;
}
public getParentLayout(): TabsLayoutManager {
return sceneGraph.getAncestor(this, TabsLayoutManager);
}
@ -217,4 +239,8 @@ export class TabItem
const duplicateTitles = parentLayout.duplicateTitles();
return !duplicateTitles.has(this.state.title);
}
private _getRepeatBehavior(): TabItemRepeaterBehavior | undefined {
return this.state.$behaviors?.find((b) => b instanceof TabItemRepeaterBehavior);
}
}

@ -1,11 +1,16 @@
import { useMemo } from 'react';
import { Input, Field } from '@grafana/ui';
import { t } from 'app/core/internationalization';
import { selectors } from '@grafana/e2e-selectors';
import { Alert, Input, Field, TextLink } from '@grafana/ui';
import { t, Trans } 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 { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard/constants';
import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource';
import { useConditionalRenderingEditor } from '../../conditional-rendering/ConditionalRenderingEditor';
import { getQueryRunnerFor, useDashboard } from '../../utils/utils';
import { useLayoutCategory } from '../layouts-shared/DashboardLayoutSelector';
import { useEditPaneInputAutoFocus } from '../layouts-shared/utils';
@ -25,9 +30,28 @@ export function useEditOptions(model: TabItem, isNewElement: boolean): OptionsPa
[model, isNewElement]
);
const repeatCategory = useMemo(
() =>
new OptionsPaneCategoryDescriptor({
title: t('dashboard.tabs-layout.tab-options.repeat.title', 'Repeat options'),
id: 'repeat-options',
isOpenDefault: false,
}).addItem(
new OptionsPaneItemDescriptor({
title: t('dashboard.tabs-layout.tab-options.repeat.variable.title', 'Repeat by variable'),
description: t(
'dashboard.tabs-layout.tab-options.repeat.variable.description',
'Repeat this tab for each value in the selected variable.'
),
render: () => <TabRepeatSelect tab={model} />,
})
),
[model]
);
const layoutCategory = useLayoutCategory(layout);
const editOptions = [tabCategory, ...layoutCategory];
const editOptions = [tabCategory, ...layoutCategory, repeatCategory];
const conditionalRenderingCategory = useMemo(
() => useConditionalRenderingEditor(model.state.conditionalRendering),
@ -62,3 +86,51 @@ function TabTitleInput({ tab, isNewElement }: { tab: TabItem; isNewElement: bool
</Field>
);
}
function TabRepeatSelect({ tab }: { tab: TabItem }) {
const { layout } = tab.useState();
const dashboard = useDashboard(tab);
const isAnyPanelUsingDashboardDS = layout.getVizPanels().some((vizPanel) => {
const runner = getQueryRunnerFor(vizPanel);
return (
runner?.state.datasource?.uid === SHARED_DASHBOARD_QUERY ||
(runner?.state.datasource?.uid === MIXED_DATASOURCE_NAME &&
runner?.state.queries.some((query) => query.datasource?.uid === SHARED_DASHBOARD_QUERY))
);
});
return (
<>
<RepeatRowSelect2
sceneContext={dashboard}
repeat={tab.getRepeatVariable()}
onChange={(repeat) => tab.onChangeRepeat(repeat)}
/>
{isAnyPanelUsingDashboardDS ? (
<Alert
data-testid={selectors.pages.Dashboard.Rows.Repeated.ConfigSection.warningMessage}
severity="warning"
title=""
topSpacing={3}
bottomSpacing={0}
>
<p>
<Trans i18nKey="dashboard.tabs-layout.tab.repeat.warning">
Panels in this tab use the {{ SHARED_DASHBOARD_QUERY }} data source. These panels will reference the panel
in the original tab, not the ones in the repeated tabs.
</Trans>
</p>
<TextLink
external
href={
'https://grafana.com/docs/grafana/latest/dashboards/build-dashboards/create-dashboard/#configure-repeating-tabs'
}
>
<Trans i18nKey="dashboard.tabs-layout.tab.repeat.learn-more">Learn more</Trans>
</TextLink>
</Alert>
) : undefined}
</>
);
}

@ -8,6 +8,7 @@ import { Tab, useElementSelection, usePointerDistance, useStyles2 } from '@grafa
import { t } from 'app/core/internationalization';
import { useIsConditionallyHidden } from '../../conditional-rendering/useIsConditionallyHidden';
import { useIsClone } from '../../utils/clone';
import { useDashboardState } from '../../utils/utils';
import { TabItem } from './TabItem';
@ -28,6 +29,9 @@ export function TabItemRenderer({ model }: SceneComponentProps<TabItem>) {
const styles = useStyles2(getStyles);
const pointerDistance = usePointerDistance();
const [isConditionallyHidden] = useIsConditionallyHidden(model);
const isClone = useIsClone(model);
const isDraggable = !isClone && isEditing;
if (isConditionallyHidden && !isEditing && !isActive) {
return null;
@ -43,7 +47,7 @@ export function TabItemRenderer({ model }: SceneComponentProps<TabItem>) {
}
return (
<Draggable key={key!} draggableId={key!} index={myIndex} isDragDisabled={!isEditing}>
<Draggable key={key!} draggableId={key!} index={myIndex} isDragDisabled={!isDraggable}>
{(dragProvided, dragSnapshot) => (
<div
ref={(ref) => dragProvided.innerRef(ref)}

@ -0,0 +1,272 @@
import { VariableRefresh } from '@grafana/data';
import { getPanelPlugin } from '@grafana/data/test';
import { setPluginImportUtils } from '@grafana/runtime';
import {
SceneGridRow,
SceneTimeRange,
SceneVariableSet,
TestVariable,
VariableValueOption,
PanelBuilders,
} from '@grafana/scenes';
import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from 'app/features/variables/constants';
import { TextMode } from 'app/plugins/panel/text/panelcfg.gen';
import { getCloneKey, isInCloneChain, joinCloneKeys } from '../../utils/clone';
import { activateFullSceneTree } from '../../utils/test-utils';
import { DashboardScene } from '../DashboardScene';
import { DashboardGridItem } from '../layout-default/DashboardGridItem';
import { DefaultGridLayoutManager } from '../layout-default/DefaultGridLayoutManager';
import { TabItem } from './TabItem';
import { TabItemRepeaterBehavior } from './TabItemRepeaterBehavior';
import { TabsLayoutManager } from './TabsLayoutManager';
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
setPluginExtensionGetter: jest.fn(),
getPluginLinkExtensions: jest.fn().mockReturnValue({ extensions: [] }),
}));
setPluginImportUtils({
importPanelPlugin: () => Promise.resolve(getPanelPlugin({})),
getPanelPluginFromCache: () => undefined,
});
describe('TabItemRepeaterBehavior', () => {
describe('Given scene with variable with 5 values', () => {
let scene: DashboardScene, layout: TabsLayoutManager, repeatBehavior: TabItemRepeaterBehavior;
let layoutStateUpdates: unknown[];
beforeEach(async () => {
({ scene, layout, repeatBehavior } = buildScene({ variableQueryTime: 0 }));
layoutStateUpdates = [];
layout.subscribeToState((state) => layoutStateUpdates.push(state));
activateFullSceneTree(scene);
await new Promise((r) => setTimeout(r, 1));
});
it('Should repeat tab', () => {
// Verify that first tab still has repeat behavior
const tab1 = layout.state.tabs[0];
expect(tab1.state.key).toBe(getCloneKey('tab-1', 0));
expect(tab1.state.$behaviors?.[0]).toBeInstanceOf(TabItemRepeaterBehavior);
expect(tab1.state.$variables!.state.variables[0].getValue()).toBe('A1');
const tab1Children = getTabChildren(tab1);
expect(tab1Children[0].state.key!).toBe(joinCloneKeys(tab1.state.key!, 'grid-item-0'));
expect(tab1Children[0].state.body?.state.key).toBe(joinCloneKeys(tab1Children[0].state.key!, 'panel-0'));
const tab2 = layout.state.tabs[1];
expect(tab2.state.key).toBe(getCloneKey('tab-1', 1));
expect(tab2.state.$behaviors).toEqual([]);
expect(tab2.state.$variables!.state.variables[0].getValueText?.()).toBe('B');
const tab2Children = getTabChildren(tab2);
expect(tab2Children[0].state.key!).toBe(joinCloneKeys(tab2.state.key!, 'grid-item-0'));
expect(tab2Children[0].state.body?.state.key).toBe(joinCloneKeys(tab2Children[0].state.key!, 'panel-0'));
});
it('Repeated tabs should be read only', () => {
const tab1 = layout.state.tabs[0];
expect(isInCloneChain(tab1.state.key!)).toBe(false);
const tab2 = layout.state.tabs[1];
expect(isInCloneChain(tab2.state.key!)).toBe(true);
});
it('Should push tab at the bottom down', () => {
// Should push tab at the bottom down
const tabAtTheBottom = layout.state.tabs[5];
expect(tabAtTheBottom.state.title).toBe('Tab at the bottom');
});
it('Should handle second repeat cycle and update remove old repeats', async () => {
// trigger another repeat cycle by changing the variable
const variable = scene.state.$variables!.state.variables[0] as TestVariable;
variable.changeValueTo(['B1', 'C1']);
await new Promise((r) => setTimeout(r, 1));
// should now only have 2 repeated tabs (and the panel above + the tab at the bottom)
expect(layout.state.tabs.length).toBe(3);
});
it('Should ignore repeat process if variable values are the same', async () => {
// trigger another repeat cycle by changing the variable
repeatBehavior.performRepeat();
await new Promise((r) => setTimeout(r, 1));
expect(layoutStateUpdates.length).toBe(1);
});
});
describe('Given scene with variable with 15 values', () => {
let scene: DashboardScene, layout: TabsLayoutManager;
let layoutStateUpdates: unknown[];
beforeEach(async () => {
({ scene, layout } = buildScene({ variableQueryTime: 0 }, [
{ label: 'A', value: 'A1' },
{ label: 'B', value: 'B1' },
{ label: 'C', value: 'C1' },
{ label: 'D', value: 'D1' },
{ label: 'E', value: 'E1' },
{ label: 'F', value: 'F1' },
{ label: 'G', value: 'G1' },
{ label: 'H', value: 'H1' },
{ label: 'I', value: 'I1' },
{ label: 'J', value: 'J1' },
{ label: 'K', value: 'K1' },
{ label: 'L', value: 'L1' },
{ label: 'M', value: 'M1' },
{ label: 'N', value: 'N1' },
{ label: 'O', value: 'O1' },
]));
layoutStateUpdates = [];
layout.subscribeToState((state) => layoutStateUpdates.push(state));
activateFullSceneTree(scene);
await new Promise((r) => setTimeout(r, 1));
});
it('Should handle second repeat cycle and update remove old repeats', async () => {
// should have 15 repeated tabs (and the panel above)
expect(layout.state.tabs.length).toBe(16);
// trigger another repeat cycle by changing the variable
const variable = scene.state.$variables!.state.variables[0] as TestVariable;
variable.changeValueTo(['B1', 'C1']);
await new Promise((r) => setTimeout(r, 1));
// should now only have 2 repeated tabs (and the panel above)
expect(layout.state.tabs.length).toBe(3);
});
});
describe('Given a scene with empty variable', () => {
it('Should preserve repeat tab', async () => {
const { scene, layout } = buildScene({ variableQueryTime: 0 }, []);
activateFullSceneTree(scene);
await new Promise((r) => setTimeout(r, 1));
// Should have 2 tabs, one without repeat and one with the dummy tab
expect(layout.state.tabs.length).toBe(2);
expect(layout.state.tabs[0].state.$behaviors?.[0]).toBeInstanceOf(TabItemRepeaterBehavior);
});
});
});
interface SceneOptions {
variableQueryTime: number;
variableRefresh?: VariableRefresh;
}
function buildTextPanel(key: string, content: string) {
const panel = PanelBuilders.text().setOption('content', content).setOption('mode', TextMode.Markdown).build();
panel.setState({ key });
return panel;
}
function buildScene(
options: SceneOptions,
variableOptions?: VariableValueOption[],
variableStateOverrides?: { isMulti: boolean }
) {
const repeatBehavior = new TabItemRepeaterBehavior({ variableName: 'server' });
const tabs = [
new TabItem({
key: 'tab-1',
$behaviors: [repeatBehavior],
layout: DefaultGridLayoutManager.fromGridItems([
new DashboardGridItem({
key: 'grid-item-1',
x: 0,
y: 11,
width: 24,
height: 5,
body: buildTextPanel('text-1', 'Panel inside repeated tab, server = $server'),
}),
]),
}),
new TabItem({
key: 'tab-2',
title: 'Tab at the bottom',
layout: DefaultGridLayoutManager.fromGridItems([
new DashboardGridItem({
key: 'grid-item-2',
x: 0,
y: 17,
body: buildTextPanel('text-2', 'Panel inside tab, server = $server'),
}),
new DashboardGridItem({
key: 'grid-item-3',
x: 0,
y: 25,
body: buildTextPanel('text-3', 'Panel inside tab, server = $server'),
}),
]),
}),
];
const layout = new TabsLayoutManager({ tabs });
const scene = new DashboardScene({
$timeRange: new SceneTimeRange({ from: 'now-6h', to: 'now' }),
$variables: new SceneVariableSet({
variables: [
new TestVariable({
name: 'server',
query: 'A.*',
value: ALL_VARIABLE_VALUE,
text: ALL_VARIABLE_TEXT,
isMulti: true,
includeAll: true,
delayMs: options.variableQueryTime,
refresh: options.variableRefresh,
optionsToReturn: variableOptions ?? [
{ label: 'A', value: 'A1' },
{ label: 'B', value: 'B1' },
{ label: 'C', value: 'C1' },
{ label: 'D', value: 'D1' },
{ label: 'E', value: 'E1' },
],
...variableStateOverrides,
}),
],
}),
body: layout,
});
const tabToRepeat = repeatBehavior.parent as SceneGridRow;
return { scene, layout, tabs, repeatBehavior, tabToRepeat };
}
function getTabLayout(tab: TabItem): DefaultGridLayoutManager {
const layout = tab.getLayout();
if (!(layout instanceof DefaultGridLayoutManager)) {
throw new Error('Invalid layout');
}
return layout;
}
function getTabChildren(tab: TabItem): DashboardGridItem[] {
const layout = getTabLayout(tab);
const filteredChildren = layout.state.grid.state.children.filter((child) => child instanceof DashboardGridItem);
if (filteredChildren.length !== layout.state.grid.state.children.length) {
throw new Error('Invalid layout');
}
return filteredChildren;
}

@ -0,0 +1,161 @@
import { isEqual } from 'lodash';
import {
LocalValueVariable,
MultiValueVariable,
sceneGraph,
SceneObjectBase,
SceneObjectState,
SceneVariableSet,
VariableDependencyConfig,
VariableValueSingle,
} from '@grafana/scenes';
import { isClonedKeyOf, getCloneKey } from '../../utils/clone';
import { getMultiVariableValues } from '../../utils/utils';
import { DashboardRepeatsProcessedEvent } from '../types/DashboardRepeatsProcessedEvent';
import { TabItem } from './TabItem';
import { TabsLayoutManager } from './TabsLayoutManager';
interface TabItemRepeaterBehaviorState extends SceneObjectState {
variableName: string;
}
export class TabItemRepeaterBehavior extends SceneObjectBase<TabItemRepeaterBehaviorState> {
protected _variableDependency = new VariableDependencyConfig(this, {
variableNames: [this.state.variableName],
onVariableUpdateCompleted: () => this.performRepeat(),
});
private _prevRepeatValues?: VariableValueSingle[];
private _clonedTabs?: TabItem[];
public constructor(state: TabItemRepeaterBehaviorState) {
super(state);
this.addActivationHandler(() => this._activationHandler());
}
private _activationHandler() {
this.performRepeat();
}
private _getTab(): TabItem {
if (!(this.parent instanceof TabItem)) {
throw new Error('RepeatedTabItemBehavior: Parent is not a TabItem');
}
return this.parent;
}
private _getLayout(): TabsLayoutManager {
const layout = this._getTab().parent;
if (!(layout instanceof TabsLayoutManager)) {
throw new Error('RepeatedTabItemBehavior: Layout is not a TabsLayoutManager');
}
return layout;
}
public performRepeat(force = false) {
if (this._variableDependency.hasDependencyInLoadingState()) {
return;
}
const variable = sceneGraph.lookupVariable(this.state.variableName, this.parent?.parent!);
if (!variable) {
console.error('RepeatedTabItemBehavior: Variable not found');
return;
}
if (!(variable instanceof MultiValueVariable)) {
console.error('RepeatedTabItemBehavior: Variable is not a MultiValueVariable');
return;
}
const tabToRepeat = this._getTab();
const layout = this._getLayout();
const { values, texts } = getMultiVariableValues(variable);
// Do nothing if values are the same
if (isEqual(this._prevRepeatValues, values) && !force) {
return;
}
this._prevRepeatValues = values;
this._clonedTabs = [];
const tabContent = tabToRepeat.getLayout();
// 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;
// Loop through variable values and create repeats
for (let tabIndex = 0; tabIndex < variableValues.length; tabIndex++) {
const isSourceTab = tabIndex === 0;
const tabClone = isSourceTab ? tabToRepeat : tabToRepeat.clone({ $behaviors: [] });
const tabCloneKey = getCloneKey(tabToRepeat.state.key!, tabIndex);
tabClone.setState({
key: tabCloneKey,
$variables: new SceneVariableSet({
variables: [
new LocalValueVariable({
name: this.state.variableName,
value: variableValues[tabIndex],
text: String(variableTexts[tabIndex]),
isMulti: variable.state.isMulti,
includeAll: variable.state.includeAll,
}),
],
}),
layout: tabContent.cloneLayout?.(tabCloneKey, isSourceTab),
});
this._clonedTabs.push(tabClone);
}
updateLayout(layout, this._clonedTabs, tabToRepeat.state.key!);
// Used from dashboard url sync
this.publishEvent(new DashboardRepeatsProcessedEvent({ source: this }), true);
}
public removeBehavior() {
const tab = this._getTab();
const layout = this._getLayout();
const tabs = getTabsFilterOutRepeatClones(layout, tab.state.key!);
layout.setState({ tabs });
// Remove behavior and the scoped local variable
tab.setState({ $behaviors: tab.state.$behaviors!.filter((b) => b !== this), $variables: undefined });
}
}
function updateLayout(layout: TabsLayoutManager, tabs: TabItem[], tabKey: string) {
const allTabs = getTabsFilterOutRepeatClones(layout, tabKey);
const index = allTabs.findIndex((tab) => tab.state.key!.includes(tabKey));
if (index === -1) {
throw new Error('TabItemRepeaterBehavior: Tab not found in layout');
}
layout.setState({ tabs: [...allTabs.slice(0, index), ...tabs, ...allTabs.slice(index + 1)] });
}
function getTabsFilterOutRepeatClones(layout: TabsLayoutManager, tabKey: string) {
return layout.state.tabs.filter((tab) => !isClonedKeyOf(tab.state.key!, tabKey));
}

@ -1,4 +1,5 @@
import {
sceneGraph,
SceneObjectBase,
SceneObjectState,
SceneObjectUrlSyncConfig,
@ -14,8 +15,10 @@ import {
ObjectsReorderedOnCanvasEvent,
} from '../../edit-pane/shared';
import { serializeTabsLayout } from '../../serialization/layoutSerializers/TabsLayoutSerializer';
import { isClonedKey, joinCloneKeys } from '../../utils/clone';
import { getDashboardSceneFor } from '../../utils/utils';
import { RowItem } from '../layout-rows/RowItem';
import { RowItemRepeaterBehavior } from '../layout-rows/RowItemRepeaterBehavior';
import { RowsLayoutManager } from '../layout-rows/RowsLayoutManager';
import { getTabFromClipboard } from '../layouts-shared/paste';
import { generateUniqueTitle, ungroupLayout } from '../layouts-shared/utils';
@ -23,6 +26,7 @@ import { DashboardLayoutManager } from '../types/DashboardLayoutManager';
import { LayoutRegistryItem } from '../types/LayoutRegistryItem';
import { TabItem } from './TabItem';
import { TabItemRepeaterBehavior } from './TabItemRepeaterBehavior';
import { TabsLayoutManagerRenderer } from './TabsLayoutManagerRenderer';
interface TabsLayoutManagerState extends SceneObjectState {
@ -122,7 +126,16 @@ export class TabsLayoutManager extends SceneObjectBase<TabsLayoutManagerState> i
}
public cloneLayout(ancestorKey: string, isSource: boolean): DashboardLayoutManager {
throw new Error('Method not implemented.');
return this.clone({
tabs: this.state.tabs.map((tab) => {
const key = joinCloneKeys(ancestorKey, tab.state.key!);
return tab.clone({
key,
layout: tab.state.layout.cloneLayout(key, isSource),
});
}),
});
}
public addNewTab(tab?: TabItem) {
@ -149,7 +162,19 @@ export class TabsLayoutManager extends SceneObjectBase<TabsLayoutManagerState> i
}
public activateRepeaters() {
this.state.tabs.forEach((tab) => tab.getLayout().activateRepeaters?.());
this.state.tabs.forEach((tab) => {
if (!tab.isActive) {
tab.activate();
}
const behavior = (tab.state.$behaviors ?? []).find((b) => b instanceof TabItemRepeaterBehavior);
if (!behavior?.isActive) {
behavior?.activate();
}
tab.getLayout().activateRepeaters?.();
});
}
public shouldUngroup(): boolean {
@ -210,7 +235,21 @@ export class TabsLayoutManager extends SceneObjectBase<TabsLayoutManagerState> i
if (layout instanceof RowsLayoutManager) {
for (const row of layout.state.rows) {
tabs.push(new TabItem({ layout: row.state.layout.clone(), title: row.state.title }));
if (isClonedKey(row.state.key!)) {
continue;
}
const conditionalRendering = row.state.conditionalRendering;
conditionalRendering?.clearParent();
const behavior = row.state.$behaviors?.find((b) => b instanceof RowItemRepeaterBehavior);
const $behaviors = !behavior
? undefined
: [new TabItemRepeaterBehavior({ variableName: behavior.state.variableName })];
tabs.push(
new TabItem({ layout: row.state.layout.clone(), title: row.state.title, conditionalRendering, $behaviors })
);
}
} else {
layout.clearParent();
@ -245,7 +284,7 @@ export class TabsLayoutManager extends SceneObjectBase<TabsLayoutManagerState> i
const duplicateTitles = new Set<string | undefined>();
this.state.tabs.forEach((tab) => {
const title = tab.state.title;
const title = sceneGraph.interpolate(tab, tab.state.title);
const count = (titleCounts.get(title) ?? 0) + 1;
titleCounts.set(title, count);
if (count > 1) {

@ -1,4 +1,3 @@
import { SceneObject } from '@grafana/scenes';
import {
Spec as DashboardV2Spec,
RowsLayoutRowKind,
@ -7,6 +6,7 @@ import {
import { RowItem } from '../../scene/layout-rows/RowItem';
import { RowItemRepeaterBehavior } from '../../scene/layout-rows/RowItemRepeaterBehavior';
import { RowsLayoutManager } from '../../scene/layout-rows/RowsLayoutManager';
import { isClonedKey } from '../../utils/clone';
import { layoutDeserializerRegistry } from './layoutSerializerRegistry';
import { getConditionalRendering } from './utils';
@ -15,7 +15,7 @@ export function serializeRowsLayout(layoutManager: RowsLayoutManager): Dashboard
return {
kind: 'RowsLayout',
spec: {
rows: layoutManager.state.rows.map(serializeRow),
rows: layoutManager.state.rows.filter((row) => !isClonedKey(row.state.key!)).map(serializeRow),
},
};
}
@ -72,17 +72,16 @@ export function deserializeRow(
panelIdGenerator?: () => number
): RowItem {
const layout = row.spec.layout;
const behaviors: SceneObject[] = [];
if (row.spec.repeat) {
behaviors.push(new RowItemRepeaterBehavior({ variableName: row.spec.repeat.value }));
}
const $behaviors = !row.spec.repeat
? undefined
: [new RowItemRepeaterBehavior({ variableName: row.spec.repeat.value })];
return new RowItem({
title: row.spec.title,
collapse: row.spec.collapse,
hideHeader: row.spec.hideHeader,
fillScreen: row.spec.fillScreen,
$behaviors: behaviors,
$behaviors,
layout: layoutDeserializerRegistry.get(layout.kind).deserialize(layout, elements, preload, panelIdGenerator),
conditionalRendering: getConditionalRendering(row),
});

@ -4,7 +4,9 @@ import {
} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen';
import { TabItem } from '../../scene/layout-tabs/TabItem';
import { TabItemRepeaterBehavior } from '../../scene/layout-tabs/TabItemRepeaterBehavior';
import { TabsLayoutManager } from '../../scene/layout-tabs/TabsLayoutManager';
import { isClonedKey } from '../../utils/clone';
import { layoutDeserializerRegistry } from './layoutSerializerRegistry';
import { getConditionalRendering } from './utils';
@ -13,7 +15,7 @@ export function serializeTabsLayout(layoutManager: TabsLayoutManager): Dashboard
return {
kind: 'TabsLayout',
spec: {
tabs: layoutManager.state.tabs.map(serializeTab),
tabs: layoutManager.state.tabs.filter((tab) => !isClonedKey(tab.state.key!)).map(serializeTab),
},
};
}
@ -34,6 +36,17 @@ export function serializeTab(tab: TabItem): TabsLayoutTabKind {
tabKind.spec.conditionalRendering = conditionalRenderingRootGroup;
}
if (tab.state.$behaviors) {
for (const behavior of tab.state.$behaviors) {
if (behavior instanceof TabItemRepeaterBehavior) {
if (tabKind.spec.repeat) {
throw new Error('Multiple repeaters are not supported');
}
tabKind.spec.repeat = { value: behavior.state.variableName, mode: 'variable' };
}
}
}
return tabKind;
}
@ -61,9 +74,14 @@ export function deserializeTab(
panelIdGenerator?: () => number
): TabItem {
const layout = tab.spec.layout;
const $behaviors = !tab.spec.repeat
? undefined
: [new TabItemRepeaterBehavior({ variableName: tab.spec.repeat.value })];
return new TabItem({
title: tab.spec.title,
layout: layoutDeserializerRegistry.get(layout.kind).deserialize(layout, elements, preload, panelIdGenerator),
$behaviors,
conditionalRendering: getConditionalRendering(tab),
});
}

@ -3107,9 +3107,20 @@
"description": "Organize panels into horizontal tabs",
"name": "Tabs",
"tab": {
"new": "New tab"
"new": "New tab",
"repeat": {
"learn-more": "Learn more",
"warning": "Panels in this tab use the {{SHARED_DASHBOARD_QUERY}} data source. These panels will reference the panel in the original tab, not the ones in the repeated tabs."
}
},
"tab-options": {
"repeat": {
"title": "Repeat options",
"variable": {
"description": "Repeat this tab for each value in the selected variable.",
"title": "Repeat by variable"
}
},
"title-not-unique": "Title should be unique",
"title-option": "Title"
},

Loading…
Cancel
Save