mirror of
https://github.com/grafana/grafana.git
synced 2025-08-01 12:42:12 +08:00
Dynamic Dashboards: Add repeats for tabs (#103348)
This commit is contained in:
@ -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"
|
||||
},
|
||||
|
Reference in New Issue
Block a user