Dynamic Dashboards: Add tracking for item actions (#111059)

This commit is contained in:
Ida Štambuk 2025-09-15 20:54:43 +02:00 committed by GitHub
parent 4583402ba9
commit 544582e495
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 337 additions and 3 deletions

View File

@ -45,6 +45,9 @@ export const versionedComponents = {
pasteTab: {
'12.1.0': 'data-testid CanvasGridAddActions paste-tab',
},
pastePanel: {
'12.1.0': 'data-testid CanvasGridAddActions paste-panel',
},
},
DashboardEditPaneSplitter: {
primaryBody: {

View File

@ -0,0 +1,93 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { selectors } from '@grafana/e2e-selectors';
import { SceneTimeRange } from '@grafana/scenes';
import { DashboardScene } from '../scene/DashboardScene';
import { RowItem } from '../scene/layout-rows/RowItem';
import { RowsLayoutManager } from '../scene/layout-rows/RowsLayoutManager';
import { TabItem } from '../scene/layout-tabs/TabItem';
import { TabsLayoutManager } from '../scene/layout-tabs/TabsLayoutManager';
import { DashboardInteractions } from '../utils/interactions';
import { activateFullSceneTree } from '../utils/test-utils';
import { DashboardEditPane } from './DashboardEditPane';
import { EditPaneHeader } from './EditPaneHeader';
import { ElementSelection } from './ElementSelection';
// Mock DashboardInteractions
jest.mock('../utils/interactions', () => ({
DashboardInteractions: {
trackRemoveRowClick: jest.fn(),
trackRemoveTabClick: jest.fn(),
},
}));
const sceneWithTab = new DashboardScene({
$timeRange: new SceneTimeRange({ from: 'now-6h', to: 'now' }),
isEditing: true,
body: new TabsLayoutManager({
tabs: [
new TabItem({
title: 'test tab',
}),
],
}),
});
const sceneWithRow = new DashboardScene({
$timeRange: new SceneTimeRange({ from: 'now-6h', to: 'now' }),
isEditing: true,
body: new RowsLayoutManager({
rows: [
new RowItem({
title: 'test row',
}),
],
}),
});
const buildTestScene = (scene: DashboardScene) => {
activateFullSceneTree(scene);
return scene;
};
describe('EditPaneHeader', () => {
const mockEditPane = {
state: { selection: null },
clearSelection: jest.fn(),
} as unknown as DashboardEditPane;
beforeEach(() => {
jest.clearAllMocks();
});
describe('tracking item deletion', () => {
it('should call DashboardActions.trackDeleteRow when deleting a row', async () => {
const user = userEvent.setup();
const scene = buildTestScene(sceneWithRow);
const row = (scene.state.body as RowsLayoutManager).state.rows[0];
const elementSelection = new ElementSelection([['row-test', row.getRef()]]);
const editableElement = elementSelection.createSelectionElement()!;
render(<EditPaneHeader element={editableElement} editPane={mockEditPane} />);
await user.click(screen.getByTestId(selectors.components.EditPaneHeader.deleteButton));
expect(DashboardInteractions.trackRemoveRowClick).toHaveBeenCalled();
});
it('should call DashboardActions.trackDeleteTab when deleting a tab', async () => {
const user = userEvent.setup();
const scene = buildTestScene(sceneWithTab);
const tab = (scene.state.body as TabsLayoutManager).state.tabs[0];
const elementSelection = new ElementSelection([['tab-test', tab.getRef()]]);
const editableElement = elementSelection.createSelectionElement()!;
render(<EditPaneHeader element={editableElement} editPane={mockEditPane} />);
await user.click(screen.getByTestId(selectors.components.EditPaneHeader.deleteButton));
expect(DashboardInteractions.trackRemoveTabClick).toHaveBeenCalled();
});
});
});

View File

@ -4,6 +4,7 @@ import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { t } from '@grafana/i18n';
import { Button, Menu, Stack, Text, useStyles2, Dropdown, Icon, IconButton } from '@grafana/ui';
import { trackDeleteDashboardElement } from 'app/features/dashboard/utils/tracking';
import { EditableDashboardElement } from '../scene/types/EditableDashboardElement';
@ -26,6 +27,15 @@ export function EditPaneHeader({ element, editPane }: EditPaneHeaderProps) {
const onGoBack = () => editPane.clearSelection();
const canGoBack = editPane.state.selection;
const onDeleteElement = () => {
if (onConfirmDelete) {
onConfirmDelete();
} else if (onDelete) {
onDelete();
}
trackDeleteDashboardElement(elementInfo);
};
return (
<div className={styles.wrapper}>
<Stack direction="row" gap={0.5}>
@ -75,7 +85,7 @@ export function EditPaneHeader({ element, editPane }: EditPaneHeaderProps) {
{(onDelete || onConfirmDelete) && (
<Button
onClick={onConfirmDelete || onDelete}
onClick={onDeleteElement}
size="sm"
variant="destructive"
fill="outline"

View File

@ -0,0 +1,159 @@
import { screen, render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { getPanelPlugin } from '@grafana/data/test';
import { selectors } from '@grafana/e2e-selectors';
import { setPluginImportUtils } from '@grafana/runtime';
import { SceneTimeRange, VizPanel } from '@grafana/scenes';
import { DashboardInteractions } from '../../utils/interactions';
import { activateFullSceneTree } from '../../utils/test-utils';
import { DashboardScene } from '../DashboardScene';
import { RowItem } from '../layout-rows/RowItem';
import { RowsLayoutManager } from '../layout-rows/RowsLayoutManager';
import { TabItem } from '../layout-tabs/TabItem';
import { TabsLayoutManager } from '../layout-tabs/TabsLayoutManager';
import { CanvasGridAddActions } from './CanvasGridAddActions';
jest.mock('../../utils/interactions', () => ({
DashboardInteractions: {
trackAddPanelClick: jest.fn(),
trackGroupRowClick: jest.fn(),
trackGroupTabClick: jest.fn(),
trackUngroupClick: jest.fn(),
trackPastePanelClick: jest.fn(),
},
}));
// mock getDefaultVizPanel
jest.mock('../../utils/utils', () => ({
...jest.requireActual('../../utils/utils'),
getDefaultVizPanel: () => new VizPanel({ key: 'panel-1', pluginId: 'text' }),
}));
// mock addNew
jest.mock('./addNew', () => ({
...jest.requireActual('./addNew'),
addNewRowTo: jest.fn(),
addNewTabTo: jest.fn(),
}));
// mock useClipboardState
jest.mock('./useClipboardState', () => ({
...jest.requireActual('./useClipboardState'),
useClipboardState: () => ({
hasCopiedPanel: true,
}),
}));
// mock ungroupLayout
jest.mock('./utils', () => ({
...jest.requireActual('./utils'),
groupLayout: jest.fn(),
}));
setPluginImportUtils({
importPanelPlugin: (id: string) => Promise.resolve(getPanelPlugin({})),
getPanelPluginFromCache: (id: string) => undefined,
});
function buildTestScene() {
const sceneWithNestedLayout = new DashboardScene({
$timeRange: new SceneTimeRange({ from: 'now-6h', to: 'now' }),
isEditing: true,
body: new TabsLayoutManager({
tabs: [
new TabItem({
title: 'test tab',
layout: new RowsLayoutManager({
rows: [
new RowItem({
title: 'Test Title',
layout: new TabsLayoutManager({
tabs: [new TabItem({ title: 'Subtab' })],
}),
}),
],
}),
}),
],
}),
});
activateFullSceneTree(sceneWithNestedLayout);
return sceneWithNestedLayout;
}
describe('CanvasGridAddActions', () => {
afterEach(() => {
jest.clearAllMocks();
});
describe('tracking scene actions', () => {
it('should call DashboardInteractions.trackAddPanelClick when clicking on add panel button', async () => {
const scene = buildTestScene();
const layoutManager = scene.state.body;
layoutManager.addPanel = jest.fn();
const user = userEvent.setup();
render(<CanvasGridAddActions layoutManager={layoutManager} />);
await user.click(await screen.findByTestId(selectors.components.CanvasGridAddActions.addPanel));
expect(DashboardInteractions.trackAddPanelClick).toHaveBeenCalled();
});
it('should call DashboardInteractions.trackGroupRowClick when clicking on group into row button', async () => {
const scene = buildTestScene();
const layoutManager = scene.state.body;
const user = userEvent.setup();
render(<CanvasGridAddActions layoutManager={layoutManager} />);
await user.click(await screen.findByTestId(selectors.components.CanvasGridAddActions.groupPanels));
await user.click(await screen.findByTestId(selectors.components.CanvasGridAddActions.addRow));
expect(DashboardInteractions.trackGroupRowClick).toHaveBeenCalled();
});
it('should call DashboardInteractions.trackGroupTabClick when clicking on group into tab', async () => {
const scene = buildTestScene();
const layoutManager = scene.state.body;
const user = userEvent.setup();
render(<CanvasGridAddActions layoutManager={layoutManager} />);
await user.click(await screen.findByTestId(selectors.components.CanvasGridAddActions.groupPanels));
await user.click(await screen.findByTestId(selectors.components.CanvasGridAddActions.addTab));
expect(DashboardInteractions.trackGroupTabClick).toHaveBeenCalled();
});
it('should call DashboardInteractions.trackUngroupClick when clicking on ungroup panels in row layout', async () => {
const scene = buildTestScene();
const layoutManager = (scene.state.body as TabsLayoutManager).state.tabs[0].state.layout as RowsLayoutManager;
const user = userEvent.setup();
render(<CanvasGridAddActions layoutManager={layoutManager} />);
await user.click(await screen.findByTestId(selectors.components.CanvasGridAddActions.ungroup));
expect(DashboardInteractions.trackUngroupClick).toHaveBeenCalled();
});
it('should call DashboardInteractions.trackUngroupClick when clicking on ungroup panels in tab layout', async () => {
const scene = buildTestScene();
const layoutManager = (
((scene.state.body as TabsLayoutManager).state.tabs[0].state.layout as RowsLayoutManager).state.rows[0].state
.layout as TabsLayoutManager
).state.tabs[0].state.layout;
const user = userEvent.setup();
render(<CanvasGridAddActions layoutManager={layoutManager} />);
await user.click(await screen.findByTestId(selectors.components.CanvasGridAddActions.ungroup));
expect(DashboardInteractions.trackUngroupClick).toHaveBeenCalled();
});
it('should call DashboardInteractions.trackPastePanel when clicking on the paste panel button', async () => {
const scene = buildTestScene();
const layoutManager = scene.state.body;
layoutManager.pastePanel = jest.fn();
const user = userEvent.setup();
render(<CanvasGridAddActions layoutManager={layoutManager} />);
await user.click(await screen.findByTestId(selectors.components.CanvasGridAddActions.pastePanel));
expect(DashboardInteractions.trackPastePanelClick).toHaveBeenCalled();
});
});
});

View File

@ -6,6 +6,7 @@ import { Trans, t } from '@grafana/i18n';
import { Button, Dropdown, Menu, useStyles2 } from '@grafana/ui';
import { dashboardSceneGraph } from '../../utils/dashboardSceneGraph';
import { DashboardInteractions } from '../../utils/interactions';
import { getDefaultVizPanel } from '../../utils/utils';
import { DashboardScene } from '../DashboardScene';
import { RowsLayoutManager } from '../layout-rows/RowsLayoutManager';
@ -31,7 +32,10 @@ export function CanvasGridAddActions({ layoutManager }: Props) {
fill="text"
icon="plus"
data-testid={selectors.components.CanvasGridAddActions.addPanel}
onClick={() => layoutManager.addPanel(getDefaultVizPanel())}
onClick={() => {
layoutManager.addPanel(getDefaultVizPanel());
DashboardInteractions.trackAddPanelClick();
}}
>
<Trans i18nKey="dashboard.canvas-actions.add-panel">Add panel</Trans>
</Button>
@ -41,15 +45,19 @@ export function CanvasGridAddActions({ layoutManager }: Props) {
<Menu.Item
icon="list-ul"
label={t('dashboard.canvas-actions.group-into-row', 'Group into row')}
testId={selectors.components.CanvasGridAddActions.addRow}
onClick={() => {
addNewRowTo(layoutManager);
DashboardInteractions.trackGroupRowClick();
}}
></Menu.Item>
<Menu.Item
icon="layers"
testId={selectors.components.CanvasGridAddActions.addTab}
label={t('dashboard.canvas-actions.group-into-tab', 'Group into tab')}
onClick={() => {
addNewTabTo(layoutManager);
DashboardInteractions.trackGroupTabClick();
}}
></Menu.Item>
</Menu>
@ -67,11 +75,13 @@ export function CanvasGridAddActions({ layoutManager }: Props) {
{renderUngroupAction(layoutManager)}
{hasCopiedPanel && layoutManager.pastePanel && (
<Button
data-testid={selectors.components.CanvasGridAddActions.pastePanel}
variant="primary"
fill="text"
icon="clipboard-alt"
onClick={() => {
layoutManager.pastePanel?.();
DashboardInteractions.trackPastePanelClick();
}}
>
<Trans i18nKey="dashboard.canvas-actions.paste-panel">Paste panel</Trans>
@ -91,6 +101,7 @@ function renderUngroupAction(layoutManager: DashboardLayoutManager) {
const parentLayout = dashboardSceneGraph.getLayoutManagerFor(layoutManager.parent!);
const onUngroup = () => {
DashboardInteractions.trackUngroupClick();
ungroupLayout(parentLayout, layoutManager);
};

View File

@ -8,6 +8,31 @@ export const DashboardInteractions = {
reportDashboardInteraction('init_dashboard_completed', { ...properties });
},
// Dashboard edit item actions
// dashboards_edit_action_clicked: when user adds or removes an item in edit mode
// props: { item: string } - item is one of: add_panel, group_row, group_tab, ungroup, paste_panel, remove_row, remove_tab
trackAddPanelClick() {
reportDashboardInteraction('edit_action_clicked', { item: 'add_panel' });
},
trackGroupRowClick() {
reportDashboardInteraction('edit_action_clicked', { item: 'group_row' });
},
trackGroupTabClick() {
reportDashboardInteraction('edit_action_clicked', { item: 'group_tab' });
},
trackUngroupClick() {
reportDashboardInteraction('edit_action_clicked', { item: 'ungroup' });
},
trackPastePanelClick() {
reportDashboardInteraction('edit_action_clicked', { item: 'paste_panel' });
},
trackRemoveRowClick() {
reportDashboardInteraction('edit_action_clicked', { item: 'remove_row' });
},
trackRemoveTabClick() {
reportDashboardInteraction('edit_action_clicked', { item: 'remove_tab' });
},
panelLinkClicked: (properties?: Record<string, unknown>) => {
reportDashboardInteraction('panelheader_datalink_clicked', properties);
},

View File

@ -3,12 +3,20 @@ import userEvent from '@testing-library/user-event';
import { createTheme } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { DashboardInteractions } from 'app/features/dashboard-scene/utils/interactions';
import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard/constants';
import { PanelModel } from '../../state/PanelModel';
import { DashboardRow, UnthemedDashboardRow } from './DashboardRow';
// mock DashboardInteractions
jest.mock('app/features/dashboard-scene/utils/interactions', () => ({
DashboardInteractions: {
trackRemoveRowClick: jest.fn(),
},
}));
describe('DashboardRow', () => {
let panel: PanelModel, dashboardMock: any;
@ -95,4 +103,11 @@ describe('DashboardRow', () => {
const dashboardRow = new UnthemedDashboardRow({ panel: rowPanel, dashboard: dashboardMock, theme: createTheme() });
expect(dashboardRow.getWarning()).not.toBeDefined();
});
it('should call DashboardInteractions.trackRemoveRowClick when clicking on delete row button', async () => {
const user = userEvent.setup();
render(<DashboardRow panel={panel} dashboard={dashboardMock} />);
await user.click(screen.getByRole('button', { name: 'Delete row' }));
expect(DashboardInteractions.trackRemoveRowClick).toHaveBeenCalled();
});
});

View File

@ -9,6 +9,7 @@ import { Trans, t } from '@grafana/i18n';
import { getTemplateSrv, RefreshEvent } from '@grafana/runtime';
import { Icon, TextLink, Themeable2, withTheme2 } from '@grafana/ui';
import appEvents from 'app/core/app_events';
import { DashboardInteractions } from 'app/features/dashboard-scene/utils/interactions';
import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard/constants';
import grabDarkSvg from 'img/grab_dark.svg';
import grabLightSvg from 'img/grab_light.svg';
@ -141,7 +142,10 @@ export class UnthemedDashboardRow extends Component<DashboardRowProps> {
<button
type="button"
className="pointer"
onClick={this.onDelete}
onClick={() => {
DashboardInteractions.trackRemoveRowClick();
this.onDelete();
}}
aria-label={t('dashboard.unthemed-dashboard-row.aria-label-delete-row', 'Delete row')}
>
<Icon name="trash-alt" />

View File

@ -1,6 +1,7 @@
import { VariableModel } from '@grafana/schema/dist/esm/index';
import { VariableKind } from '@grafana/schema/dist/esm/schema/dashboard/v2';
import { DashboardScene } from 'app/features/dashboard-scene/scene/DashboardScene';
import { EditableDashboardElementInfo } from 'app/features/dashboard-scene/scene/types/EditableDashboardElement';
import { DashboardInteractions } from 'app/features/dashboard-scene/utils/interactions';
import { DashboardModel } from '../state/DashboardModel';
@ -38,6 +39,19 @@ export function trackDashboardSceneLoaded(dashboard: DashboardScene, duration?:
});
}
export const trackDeleteDashboardElement = (element: EditableDashboardElementInfo) => {
switch (element?.typeName) {
case 'Row':
DashboardInteractions.trackRemoveRowClick();
break;
case 'Tab':
DashboardInteractions.trackRemoveTabClick();
break;
default:
break;
}
};
export function getPanelPluginCounts(panels: string[] | string[]) {
return panels.reduce((r: Record<string, number>, p) => {
r[panelName(p)] = 1 + r[panelName(p)] || 1;