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:
@@ -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',
|
||||
}),
|
||||
});
|
||||
-272
@@ -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);
|
||||
|
||||
+21
-3
@@ -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',
|
||||
|
||||
+7
-16
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user