diff --git a/apps/dashboard/kinds/v2alpha1/dashboard_spec.cue b/apps/dashboard/kinds/v2alpha1/dashboard_spec.cue index 5b52aef5a6e..7ac0428493b 100644 --- a/apps/dashboard/kinds/v2alpha1/dashboard_spec.cue +++ b/apps/dashboard/kinds/v2alpha1/dashboard_spec.cue @@ -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: { diff --git a/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec_gen.go b/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec_gen.go index 6b5511a751d..f104c3fdf45 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec_gen.go +++ b/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec_gen.go @@ -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 { diff --git a/apps/dashboard/pkg/apis/dashboard/v2alpha1/zz_generated.openapi.go b/apps/dashboard/pkg/apis/dashboard/v2alpha1/zz_generated.openapi.go index 0bed9c70b5c..e2f3b302f99 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2alpha1/zz_generated.openapi.go +++ b/apps/dashboard/pkg/apis/dashboard/v2alpha1/zz_generated.openapi.go @@ -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"}, } } 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 41fd55839ac..4e15cffeb3d 100644 --- a/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue +++ b/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue @@ -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 -} \ No newline at end of file +} 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 a53da1e3aad..3106f1b6f8f 100644 --- a/packages/grafana-schema/src/schema/dashboard/v2alpha0/types.gen.ts +++ b/packages/grafana-schema/src/schema/dashboard/v2alpha0/types.gen.ts @@ -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(), }); - diff --git a/packages/grafana-schema/src/schema/dashboard/v2alpha1/types.spec.gen.ts b/packages/grafana-schema/src/schema/dashboard/v2alpha1/types.spec.gen.ts index fb3b3b17e33..1a4fad82b96 100644 --- a/packages/grafana-schema/src/schema/dashboard/v2alpha1/types.spec.gen.ts +++ b/packages/grafana-schema/src/schema/dashboard/v2alpha1/types.spec.gen.ts @@ -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: [], }); - diff --git a/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v2alpha1.json b/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v2alpha1.json index a179f013d4d..556cc089928 100644 --- a/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v2alpha1.json +++ b/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v2alpha1.json @@ -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" } diff --git a/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx b/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx index 264eb8d236f..7cd516afd99 100644 --- a/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx @@ -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 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 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 i const duplicateTitles = new Set(); 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) { diff --git a/public/app/features/dashboard-scene/scene/layout-tabs/TabItem.tsx b/public/app/features/dashboard-scene/scene/layout-tabs/TabItem.tsx index 39e1de86928..17f033999b9 100644 --- a/public/app/features/dashboard-scene/scene/layout-tabs/TabItem.tsx +++ b/public/app/features/dashboard-scene/scene/layout-tabs/TabItem.tsx @@ -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); + } } diff --git a/public/app/features/dashboard-scene/scene/layout-tabs/TabItemEditor.tsx b/public/app/features/dashboard-scene/scene/layout-tabs/TabItemEditor.tsx index 9958e0c87ce..84e7260de10 100644 --- a/public/app/features/dashboard-scene/scene/layout-tabs/TabItemEditor.tsx +++ b/public/app/features/dashboard-scene/scene/layout-tabs/TabItemEditor.tsx @@ -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: () => , + }) + ), + [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 ); } + +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 ( + <> + tab.onChangeRepeat(repeat)} + /> + {isAnyPanelUsingDashboardDS ? ( + +

+ + 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. + +

+ + Learn more + +
+ ) : undefined} + + ); +} diff --git a/public/app/features/dashboard-scene/scene/layout-tabs/TabItemRenderer.tsx b/public/app/features/dashboard-scene/scene/layout-tabs/TabItemRenderer.tsx index 2400ce19cfb..178a6870967 100644 --- a/public/app/features/dashboard-scene/scene/layout-tabs/TabItemRenderer.tsx +++ b/public/app/features/dashboard-scene/scene/layout-tabs/TabItemRenderer.tsx @@ -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) { 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) { } return ( - + {(dragProvided, dragSnapshot) => (
dragProvided.innerRef(ref)} diff --git a/public/app/features/dashboard-scene/scene/layout-tabs/TabItemRepeaterBehavior.test.tsx b/public/app/features/dashboard-scene/scene/layout-tabs/TabItemRepeaterBehavior.test.tsx new file mode 100644 index 00000000000..93a0d400a71 --- /dev/null +++ b/public/app/features/dashboard-scene/scene/layout-tabs/TabItemRepeaterBehavior.test.tsx @@ -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; +} diff --git a/public/app/features/dashboard-scene/scene/layout-tabs/TabItemRepeaterBehavior.ts b/public/app/features/dashboard-scene/scene/layout-tabs/TabItemRepeaterBehavior.ts new file mode 100644 index 00000000000..a0e0207c7ad --- /dev/null +++ b/public/app/features/dashboard-scene/scene/layout-tabs/TabItemRepeaterBehavior.ts @@ -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 { + 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)); +} diff --git a/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManager.tsx b/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManager.tsx index 9be608411c1..ba0bcb661eb 100644 --- a/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManager.tsx @@ -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 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 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 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 i const duplicateTitles = new Set(); 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) { diff --git a/public/app/features/dashboard-scene/serialization/layoutSerializers/RowsLayoutSerializer.ts b/public/app/features/dashboard-scene/serialization/layoutSerializers/RowsLayoutSerializer.ts index b6fae840629..49a8cf94870 100644 --- a/public/app/features/dashboard-scene/serialization/layoutSerializers/RowsLayoutSerializer.ts +++ b/public/app/features/dashboard-scene/serialization/layoutSerializers/RowsLayoutSerializer.ts @@ -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), }); diff --git a/public/app/features/dashboard-scene/serialization/layoutSerializers/TabsLayoutSerializer.ts b/public/app/features/dashboard-scene/serialization/layoutSerializers/TabsLayoutSerializer.ts index 6ab820816d1..9a8cbe50bdb 100644 --- a/public/app/features/dashboard-scene/serialization/layoutSerializers/TabsLayoutSerializer.ts +++ b/public/app/features/dashboard-scene/serialization/layoutSerializers/TabsLayoutSerializer.ts @@ -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), }); } diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 823fddaec55..c0550c104e6 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -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" },