TabsLayout: Rethinking tab repeats (#108134)

* wip: rework tab repeat behaviour

* wip: tab repeats rendering

* wip: tab repeats rework

* rework tab drag and drop to account for repeats

* add TabItemRepeater tests

* clean up

* prevent canvas actions in cloned tabs and rows

* add repeat name into tab title

* prettier ffix

* fix cloneLayout bug

* remove experimental title adjustment on repeat
This commit is contained in:
Sergej-Vlasov
2025-08-08 13:42:51 +01:00
committed by GitHub
parent f3ca49f2b3
commit e1e9a570af
13 changed files with 481 additions and 522 deletions
@@ -17,7 +17,6 @@ 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';
@@ -196,14 +195,12 @@ export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> i
const conditionalRendering = tab.state.conditionalRendering;
conditionalRendering?.clearParent();
const behavior = tab.state.$behaviors?.find((b) => b instanceof TabItemRepeaterBehavior);
rows.push(
new RowItem({
layout: tab.state.layout.clone(),
title: tab.state.title,
conditionalRendering,
repeatByVariable: behavior?.state.variableName,
repeatByVariable: tab.state.repeatByVariable,
})
);
}
@@ -7,6 +7,7 @@ import { Trans } from '@grafana/i18n';
import { MultiValueVariable, SceneComponentProps, sceneGraph, useSceneObjectState } from '@grafana/scenes';
import { Button, useStyles2 } from '@grafana/ui';
import { isInCloneChain } from '../../utils/clone';
import { useDashboardState } from '../../utils/utils';
import { useClipboardState } from '../layouts-shared/useClipboardState';
@@ -20,6 +21,8 @@ export function RowLayoutManagerRenderer({ model }: SceneComponentProps<RowsLayo
const styles = useStyles2(getStyles);
const { hasCopiedRow } = useClipboardState();
const isClone = isInCloneChain(rows[0]?.state.key || '');
return (
<DragDropContext
onBeforeDragStart={(start) => model.forceSelectRow(start.draggableId)}
@@ -42,7 +45,7 @@ export function RowLayoutManagerRenderer({ model }: SceneComponentProps<RowsLayo
<RowWrapper row={row} manager={model} key={row.state.key!} />
))}
{dropProvided.placeholder}
{isEditing && (
{isEditing && !isClone && (
<div className="dashboard-canvas-add-button">
<Button
icon="plus"
@@ -32,7 +32,6 @@ 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';
@@ -41,6 +40,8 @@ export interface TabItemState extends SceneObjectState {
title?: string;
isDropTarget?: boolean;
conditionalRendering?: ConditionalRendering;
repeatByVariable?: string;
repeatedTabs?: TabItem[];
}
export class TabItem
@@ -179,19 +180,10 @@ export class TabItem
}
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();
this.setState({ repeatByVariable: repeat });
} else {
repeatBehavior?.removeBehavior();
this.setState({ repeatedTabs: undefined, $variables: undefined, repeatByVariable: undefined });
}
}
@@ -218,10 +210,6 @@ export class TabItem
}
}
public getRepeatVariable(): string | undefined {
return this._getRepeatBehavior()?.state.variableName;
}
public getParentLayout(): TabsLayoutManager {
return sceneGraph.getAncestor(this, TabsLayoutManager);
}
@@ -240,8 +228,4 @@ 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);
}
}
@@ -105,7 +105,7 @@ function TabRepeatSelect({ tab }: { tab: TabItem }) {
<>
<RepeatRowSelect2
sceneContext={dashboard}
repeat={tab.getRepeatVariable()}
repeat={tab.state.repeatByVariable}
onChange={(repeat) => tab.onChangeRepeat(repeat)}
/>
{isAnyPanelUsingDashboardDS ? (
@@ -16,13 +16,13 @@ import { TabItem } from './TabItem';
export function TabItemRenderer({ model }: SceneComponentProps<TabItem>) {
const { title, key, isDropTarget } = model.useState();
const parentLayout = model.getParentLayout();
const { tabs, currentTabIndex } = parentLayout.useState();
const { currentTabIndex } = parentLayout.useState();
const titleInterpolated = sceneGraph.interpolate(model, title, undefined, 'text');
const { isSelected, onSelect, isSelectable } = useElementSelection(key);
const { isEditing } = useDashboardState(model);
const mySlug = model.getSlug();
const urlKey = parentLayout.getUrlKey();
const myIndex = tabs.findIndex((tab) => tab === model);
const myIndex = parentLayout.getTabs().findIndex((tab) => tab === model);
const isActive = myIndex === currentTabIndex;
const location = useLocation();
const href = textUtil.sanitize(locationUtil.getUrlForPartial(location, { [urlKey]: mySlug }));
@@ -0,0 +1,165 @@
import { act, screen, waitFor } from '@testing-library/react';
import { render } from 'test/test-utils';
import { VariableRefresh } from '@grafana/data';
import { getPanelPlugin } from '@grafana/data/test';
import { setPluginImportUtils } from '@grafana/runtime';
import { 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 { DashboardScene } from '../DashboardScene';
import { AutoGridItem } from '../layout-auto-grid/AutoGridItem';
import { AutoGridLayout } from '../layout-auto-grid/AutoGridLayout';
import { AutoGridLayoutManager } from '../layout-auto-grid/AutoGridLayoutManager';
import { TabItem } from './TabItem';
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('TabItemRepeater', () => {
describe('Given scene with variable with 3 values', () => {
it('Should repeat tab', async () => {
const { tabToRepeat } = renderScene({ variableQueryTime: 0 });
await waitFor(() => {
expect(screen.queryByText('Tab A')).toBeInTheDocument();
expect(screen.queryByText('Tab B')).toBeInTheDocument();
expect(screen.queryByText('Tab C')).toBeInTheDocument();
});
expect(tabToRepeat.state.key).toBe('tab-1-clone-0');
expect(tabToRepeat.state.repeatedTabs!.length).toBe(2);
expect(tabToRepeat.state.repeatedTabs![0].state.key).toBe('tab-1-clone-1');
});
it('Should update repeats when variable value changes', async () => {
const { repeatByVariable, tabToRepeat } = renderScene({ variableQueryTime: 0 });
await waitFor(() => {
expect(screen.queryByText('Tab C')).toBeInTheDocument();
});
act(() => {
repeatByVariable.changeValueTo(['C', 'D']);
});
await waitFor(() => {
expect(screen.queryByText('Tab A')).not.toBeInTheDocument();
expect(screen.queryByText('Tab D')).toBeInTheDocument();
});
expect(tabToRepeat.state.repeatedTabs!.length).toBe(1);
});
it('Should skip update repeats when variable values the same', async () => {
const { repeatByVariable, tabToRepeat } = renderScene({ variableQueryTime: 0 });
let stateUpdates = 0;
tabToRepeat.subscribeToState((s) => stateUpdates++);
await waitFor(() => {
expect(screen.queryByText('Tab C')).toBeInTheDocument();
});
act(() => {
repeatByVariable.changeValueTo(['A1', 'B1', 'C1']);
});
expect(stateUpdates).toBe(1);
});
it('Should handle removing repeats', async () => {
const { tabToRepeat } = renderScene({ variableQueryTime: 0 });
await waitFor(() => {
expect(screen.queryByText('Tab C')).toBeInTheDocument();
});
act(() => {
tabToRepeat.onChangeRepeat(undefined);
});
expect(screen.queryByText('Tab C')).not.toBeInTheDocument();
expect(tabToRepeat.state.$variables).toBe(undefined);
expect(tabToRepeat.state.repeatedTabs).toBe(undefined);
expect(tabToRepeat.state.repeatByVariable).toBe(undefined);
});
});
});
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 renderScene(
options: SceneOptions,
variableOptions?: VariableValueOption[],
variableStateOverrides?: { isMulti: boolean }
) {
const tabs = [
new TabItem({
key: 'tab-1',
title: 'Tab $server',
repeatByVariable: 'server',
layout: new AutoGridLayoutManager({
layout: new AutoGridLayout({
children: [
new AutoGridItem({
body: buildTextPanel('text-1', 'Panel inside repeated tab, server = $server'),
}),
],
}),
}),
}),
];
const layout = new TabsLayoutManager({ tabs });
const repeatByVariable = 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' },
],
...variableStateOverrides,
});
const scene = new DashboardScene({
$timeRange: new SceneTimeRange({ from: 'now-6h', to: 'now' }),
$variables: new SceneVariableSet({
variables: [repeatByVariable],
}),
body: layout,
});
const tabToRepeat = tabs[0];
render(<scene.Component model={scene} />);
return { scene, layout, tabs, tabToRepeat, repeatByVariable };
}
@@ -0,0 +1,192 @@
import { css } from '@emotion/css';
import { isEqual } from 'lodash';
import { useEffect } from 'react';
import { t } from '@grafana/i18n';
import {
MultiValueVariable,
SceneVariableSet,
LocalValueVariable,
sceneGraph,
VariableValueSingle,
} from '@grafana/scenes';
import { Spinner, Tooltip, useStyles2 } from '@grafana/ui';
import { DashboardStateChangedEvent } from '../../edit-pane/shared';
import { getCloneKey } from '../../utils/clone';
import { dashboardLog, getMultiVariableValues } from '../../utils/utils';
import { DashboardRepeatsProcessedEvent } from '../types/DashboardRepeatsProcessedEvent';
import { TabItem } from './TabItem';
import { TabsLayoutManager } from './TabsLayoutManager';
export interface Props {
tab: TabItem;
manager: TabsLayoutManager;
variable: MultiValueVariable;
}
export function TabItemRepeater({
tab,
variable,
}: {
tab: TabItem;
manager: TabsLayoutManager;
variable: MultiValueVariable;
}) {
const { repeatedTabs } = tab.useState();
const styles = useStyles2(getStyles);
// Subscribe to variable state changes and perform repeats when the variable changes
useEffect(() => {
performTabRepeats(variable, tab, false);
const variableChangeSub = variable.subscribeToState((state) => performTabRepeats(variable, tab, false));
const editEventSub = tab.subscribeToEvent(DashboardStateChangedEvent, (e) =>
performTabRepeats(variable, tab, true)
);
return () => {
editEventSub.unsubscribe();
variableChangeSub.unsubscribe();
};
}, [variable, tab]);
if (
repeatedTabs === undefined ||
sceneGraph.hasVariableDependencyInLoadingState(variable) ||
variable.state.loading
) {
dashboardLog.logger('TabItemRepeater', false, 'Variable is loading, showing spinner');
return (
<Tooltip content={t('dashboard.tabs-layout.tab.repeat.loading', 'Loading tab repeats')}>
<div className={styles.spinnerWrapper}>
<Spinner />
</div>
</Tooltip>
);
}
return (
<>
<tab.Component model={tab} key={tab.state.key!} />
{repeatedTabs?.map((tabClone) => (
<tabClone.Component model={tabClone} key={tabClone.state.key!} />
))}
</>
);
}
export function performTabRepeats(variable: MultiValueVariable, tab: TabItem, contentChanged: boolean) {
if (sceneGraph.hasVariableDependencyInLoadingState(variable)) {
dashboardLog.logger('TabItemRepeater', false, 'Skipped dependency in loading state');
return;
}
if (variable.state.loading) {
dashboardLog.logger('TabItemRepeater', false, 'Skipped, variable is loading');
return;
}
const { values, texts } = getMultiVariableValues(variable);
const prevValues = getPrevRepeatValues(tab, variable.state.name);
if (!contentChanged && isEqual(prevValues, values)) {
dashboardLog.logger('TabItemRepeater', false, 'Skipped, values the same');
return;
}
if (contentChanged) {
dashboardLog.logger('TabItemRepeater', false, 'Performing repeats, contentChanged');
} else {
dashboardLog.logger('TabItemRepeater', false, 'Performing repeats, variable values changed', values);
}
const clonedTabs = createTabRepeats({ values, texts, variable, tab });
tab.setState({ repeatedTabs: clonedTabs });
tab.publishEvent(new DashboardRepeatsProcessedEvent({ source: tab }), true);
}
/**
* Get previous variable values given the current repeated state
*/
function getPrevRepeatValues(mainTab: TabItem, varName: string): VariableValueSingle[] {
const values: VariableValueSingle[] = [];
if (!mainTab.state.repeatedTabs) {
return [];
}
function collectVariableValue(tab: TabItem) {
const variable = sceneGraph.lookupVariable(varName, tab);
if (variable) {
const value = variable.getValue();
if (value != null && !Array.isArray(value)) {
values.push(value);
}
}
}
collectVariableValue(mainTab);
for (const tab of mainTab.state.repeatedTabs) {
collectVariableValue(tab);
}
return values;
}
export function createTabRepeats({
values,
texts,
variable,
tab,
}: {
values: VariableValueSingle[];
texts: VariableValueSingle[];
variable: MultiValueVariable;
tab: TabItem;
}) {
const variableValues = values.length ? values : [''];
const variableTexts = texts.length ? texts : variable.hasAllValue() ? ['All'] : ['None'];
const repeats: TabItem[] = [];
// Loop through variable values and create repeats
for (let tabIndex = 0; tabIndex < variableValues.length; tabIndex++) {
const isSourceTab = tabIndex === 0;
const tabCloneKey = getCloneKey(tab.state.key!, tabIndex);
const tabClone = isSourceTab
? tab
: tab.clone({ repeatByVariable: undefined, repeatedTabs: undefined, layout: undefined });
const layout = isSourceTab ? tab.getLayout() : tab.getLayout().cloneLayout(tabCloneKey, false);
tabClone.setState({
key: tabCloneKey,
$variables: new SceneVariableSet({
variables: [
new LocalValueVariable({
name: variable.state.name,
value: variableValues[tabIndex],
text: String(variableTexts[tabIndex]),
isMulti: variable.state.isMulti,
includeAll: variable.state.includeAll,
}),
],
}),
layout,
});
if (!isSourceTab) {
repeats.push(tabClone);
}
}
return repeats;
}
const getStyles = () => ({
spinnerWrapper: css({
alignSelf: 'center',
}),
});
@@ -1,272 +0,0 @@
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;
}
@@ -1,161 +0,0 @@
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,6 +1,7 @@
import { t } from '@grafana/i18n';
import {
sceneGraph,
SceneObject,
SceneObjectBase,
SceneObjectState,
SceneObjectUrlSyncConfig,
@@ -11,7 +12,15 @@ import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboa
import { dashboardEditActions, ObjectsReorderedOnCanvasEvent } from '../../edit-pane/shared';
import { serializeTabsLayout } from '../../serialization/layoutSerializers/TabsLayoutSerializer';
import { isClonedKey, joinCloneKeys } from '../../utils/clone';
import {
containsCloneKey,
getCloneKey,
getLastKeyFromClone,
getOriginalKey,
isClonedKey,
isClonedKeyOf,
joinCloneKeys,
} from '../../utils/clone';
import { getDashboardSceneFor } from '../../utils/utils';
import { RowItem } from '../layout-rows/RowItem';
import { RowsLayoutManager } from '../layout-rows/RowsLayoutManager';
@@ -21,7 +30,6 @@ 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 {
@@ -88,7 +96,7 @@ export class TabsLayoutManager extends SceneObjectBase<TabsLayoutManagerState> i
if (typeof values[key] === 'string') {
// find tab with matching slug
const matchIndex = this.state.tabs.findIndex((tab) => tab.getSlug() === urlValue);
const matchIndex = this.getTabs().findIndex((tab) => tab.getSlug() === urlValue);
if (matchIndex !== -1) {
this.setState({ currentTabIndex: matchIndex });
}
@@ -96,13 +104,22 @@ export class TabsLayoutManager extends SceneObjectBase<TabsLayoutManagerState> i
}
public switchToTab(tab: TabItem) {
this.setState({ currentTabIndex: this.state.tabs.indexOf(tab) });
this.setState({ currentTabIndex: this.getTabs().indexOf(tab) });
}
public getCurrentTab(): TabItem {
return this.state.tabs.length > this.state.currentTabIndex
? this.state.tabs[this.state.currentTabIndex]
: this.state.tabs[0];
return this.getTabs().length > this.state.currentTabIndex
? this.getTabs()[this.state.currentTabIndex]
: this.getTabs()[0];
}
public getTabs(): TabItem[] {
const tabsWithRepeats = this.state.tabs.reduce<TabItem[]>((acc, tab) => {
acc.push(tab, ...(tab.state.repeatedTabs ?? []));
return acc;
}, []);
return tabsWithRepeats;
}
public addPanel(vizPanel: VizPanel) {
@@ -112,7 +129,7 @@ export class TabsLayoutManager extends SceneObjectBase<TabsLayoutManagerState> i
public getVizPanels(): VizPanel[] {
const panels: VizPanel[] = [];
for (const tab of this.state.tabs) {
for (const tab of this.getTabs()) {
const innerPanels = tab.getLayout().getVizPanels();
panels.push(...innerPanels);
}
@@ -134,12 +151,28 @@ export class TabsLayoutManager extends SceneObjectBase<TabsLayoutManagerState> i
}
public getOutlineChildren() {
return this.state.tabs;
const outlineChildren: SceneObject[] = [];
for (const tab of this.state.tabs) {
outlineChildren.push(tab);
if (tab.state.repeatedTabs) {
for (const clone of tab.state.repeatedTabs!) {
outlineChildren.push(clone);
}
}
}
return outlineChildren;
}
public addNewTab(tab?: TabItem) {
const newTab = tab ?? new TabItem({});
const existingNames = new Set(this.state.tabs.map((tab) => tab.state.title).filter((title) => title !== undefined));
const existingNames = new Set(
this.getTabs()
.map((tab) => tab.state.title)
.filter((title) => title !== undefined)
);
const newTitle = generateUniqueTitle(newTab.state.title, existingNames);
if (newTitle !== newTab.state.title) {
newTab.setState({ title: newTitle });
@@ -148,9 +181,9 @@ export class TabsLayoutManager extends SceneObjectBase<TabsLayoutManagerState> i
dashboardEditActions.addElement({
addedObject: newTab,
source: this,
perform: () => this.setState({ tabs: [...this.state.tabs, newTab], currentTabIndex: this.state.tabs.length }),
perform: () => this.setState({ tabs: [...this.state.tabs, newTab], currentTabIndex: this.getTabs().length }),
undo: () => {
const indexOfNewTab = this.state.tabs.findIndex((t) => t === newTab);
const indexOfNewTab = this.getTabs().findIndex((t) => t === newTab);
this.setState({
tabs: this.state.tabs.filter((t) => t !== newTab),
// if the new tab was the current tab, set the current tab to the previous tab
@@ -175,24 +208,8 @@ export class TabsLayoutManager extends SceneObjectBase<TabsLayoutManagerState> i
this.addNewTab(tab);
}
public 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 {
return this.state.tabs.length === 1;
return this.getTabs().length === 1;
}
public removeTab(tabToRemove: TabItem) {
@@ -234,31 +251,54 @@ export class TabsLayoutManager extends SceneObjectBase<TabsLayoutManagerState> i
}
public moveTab(fromIndex: number, toIndex: number) {
const objectToMove = this.state.tabs[fromIndex];
// fromIndex and toIndex include repeated tab so we need to find original indexes
const allTabs = this.getTabs();
const objectToMove = allTabs[fromIndex];
let destinationTab = allTabs[toIndex];
let selectionIndex = toIndex;
if (containsCloneKey(getLastKeyFromClone(destinationTab.state.key!))) {
if (isClonedKeyOf(destinationTab.state.key!, objectToMove.state.key!)) {
// moving tab between its clones
return;
}
const originalTabKey = getCloneKey(getOriginalKey(destinationTab.state.key!), 0);
const originalTabIndex = allTabs.findIndex((tab) => tab.state.key === originalTabKey);
if (originalTabIndex !== -1) {
destinationTab = allTabs[originalTabIndex];
const isMovingLeft = toIndex < fromIndex;
selectionIndex = originalTabIndex + (isMovingLeft ? 0 : destinationTab.state.repeatedTabs?.length || 0);
}
}
const originalFromIndex = this.state.tabs.findIndex((tab) => tab === objectToMove);
const originalToIndex = this.state.tabs.findIndex((tab) => tab === destinationTab);
dashboardEditActions.moveElement({
source: this,
movedObject: objectToMove,
perform: () => {
this.rearrangeTabs(fromIndex, toIndex);
this.rearrangeTabs(originalFromIndex, originalToIndex, selectionIndex);
},
undo: () => {
this.rearrangeTabs(toIndex, fromIndex);
this.rearrangeTabs(originalToIndex, originalFromIndex, fromIndex);
},
});
}
private rearrangeTabs(fromIndex: number, toIndex: number) {
private rearrangeTabs(fromIndex: number, toIndex: number, selectedTabIndex: number) {
const tabs = [...this.state.tabs];
const [removed] = tabs.splice(fromIndex, 1);
tabs.splice(toIndex, 0, removed);
this.setState({ tabs, currentTabIndex: toIndex });
this.setState({ tabs, currentTabIndex: selectedTabIndex });
this.publishEvent(new ObjectsReorderedOnCanvasEvent(this), true);
}
public forceSelectTab(tabKey: string) {
const tabIndex = this.state.tabs.findIndex((tab) => tab.state.key === tabKey);
const tab = this.state.tabs[tabIndex];
const tabIndex = this.getTabs().findIndex((tab) => tab.state.key === tabKey);
const tab = this.getTabs()[tabIndex];
if (!tab) {
return;
@@ -286,12 +326,13 @@ export class TabsLayoutManager extends SceneObjectBase<TabsLayoutManagerState> i
const conditionalRendering = row.state.conditionalRendering;
conditionalRendering?.clearParent();
const $behaviors = row.state.repeatByVariable
? [new TabItemRepeaterBehavior({ variableName: row.state.repeatByVariable })]
: undefined;
tabs.push(
new TabItem({ layout: row.state.layout.clone(), title: row.state.title, conditionalRendering, $behaviors })
new TabItem({
layout: row.state.layout.clone(),
title: row.state.title,
conditionalRendering,
repeatByVariable: row.state.repeatByVariable,
})
);
}
} else {
@@ -326,7 +367,7 @@ export class TabsLayoutManager extends SceneObjectBase<TabsLayoutManagerState> i
const titleCounts = new Map<string | undefined, number>();
const duplicateTitles = new Set<string | undefined>();
this.state.tabs.forEach((tab) => {
this.getTabs().forEach((tab) => {
const title = sceneGraph.interpolate(tab, tab.state.title);
const count = (titleCounts.get(title) ?? 0) + 1;
titleCounts.set(title, count);
@@ -4,14 +4,17 @@ import { DragDropContext, Droppable } from '@hello-pangea/dnd';
import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { Trans } from '@grafana/i18n';
import { SceneComponentProps } from '@grafana/scenes';
import { MultiValueVariable, SceneComponentProps, sceneGraph, useSceneObjectState } from '@grafana/scenes';
import { Button, TabContent, TabsBar, useStyles2 } from '@grafana/ui';
import { useIsConditionallyHidden } from '../../conditional-rendering/useIsConditionallyHidden';
import { isInCloneChain } from '../../utils/clone';
import { getDashboardSceneFor } from '../../utils/utils';
import { dashboardCanvasAddButtonHoverStyles } from '../layouts-shared/styles';
import { useClipboardState } from '../layouts-shared/useClipboardState';
import { TabItem } from './TabItem';
import { TabItemRepeater } from './TabItemRepeater';
import { TabsLayoutManager } from './TabsLayoutManager';
export function TabsLayoutManagerRenderer({ model }: SceneComponentProps<TabsLayoutManager>) {
@@ -24,6 +27,8 @@ export function TabsLayoutManagerRenderer({ model }: SceneComponentProps<TabsLay
const { hasCopiedTab } = useClipboardState();
const [_, conditionalRenderingClass, conditionalRenderingOverlay] = useIsConditionallyHidden(currentTab);
const isClone = isInCloneChain(tabs[0]?.state.key || '');
return (
<div className={styles.tabLayoutContainer}>
<TabsBar className={styles.tabsBar}>
@@ -46,14 +51,14 @@ export function TabsLayoutManagerRenderer({ model }: SceneComponentProps<TabsLay
{(dropProvided) => (
<div className={styles.tabsContainer} ref={dropProvided.innerRef} {...dropProvided.droppableProps}>
{tabs.map((tab) => (
<tab.Component model={tab} key={tab.state.key!} />
<TabWrapper tab={tab} manager={model} key={tab.state.key!} />
))}
{dropProvided.placeholder}
</div>
)}
</Droppable>
{isEditing && (
{isEditing && !isClone && (
<div className="dashboard-canvas-add-button">
<Button
icon="plus"
@@ -97,6 +102,19 @@ export function TabsLayoutManagerRenderer({ model }: SceneComponentProps<TabsLay
);
}
function TabWrapper({ tab, manager }: { tab: TabItem; manager: TabsLayoutManager }) {
const { repeatByVariable } = useSceneObjectState(tab, { shouldActivateOrKeepAlive: true });
if (repeatByVariable) {
const variable = sceneGraph.lookupVariable(repeatByVariable, manager);
if (variable instanceof MultiValueVariable) {
return <TabItemRepeater tab={tab} key={tab.state.key!} manager={manager} variable={variable} />;
}
}
return <tab.Component model={tab} key={tab.state.key!} />;
}
const getStyles = (theme: GrafanaTheme2) => ({
tabLayoutContainer: css({
display: 'flex',
@@ -1,7 +1,6 @@
import { Spec as DashboardV2Spec, TabsLayoutTabKind } from '@grafana/schema/dist/esm/schema/dashboard/v2';
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';
@@ -24,6 +23,12 @@ export function serializeTab(tab: TabItem): TabsLayoutTabKind {
spec: {
title: tab.state.title,
layout: layout,
...(tab.state.repeatByVariable && {
repeat: {
mode: 'variable',
value: tab.state.repeatByVariable,
},
}),
},
};
@@ -33,17 +38,6 @@ 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;
}
@@ -71,14 +65,11 @@ 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,
repeatByVariable: tab.spec.repeat?.value,
conditionalRendering: getConditionalRendering(tab),
});
}
+1
View File
@@ -5233,6 +5233,7 @@
"new": "New tab",
"repeat": {
"learn-more": "Learn more",
"loading": "Loading tab repeats",
"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."
}
},